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:
2026-07-18 12:54:10 +00:00
parent 92d303ed01
commit 375c7ec60b
64 changed files with 5169 additions and 2404 deletions

View File

@@ -70,8 +70,13 @@ function parseRoomNumber(input: string) {
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
let roomType = '四人间';
let capacity = 4;
if (bldgNum === '2') { roomType = '单人间'; capacity = 1; }
else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; }
if (bldgNum === '2') {
roomType = '单人间';
capacity = 1;
} else if (bldgNum === '8') {
roomType = '爆改房';
capacity = 2;
}
return { building: `${bldgNum}号楼`, floor, roomType, capacity };
}
return null;
@@ -150,12 +155,19 @@ const RoomsPage: React.FC = () => {
let result = data;
if (searchText) {
const keyword = searchText.toLowerCase();
result = result.filter((r: Record<string, unknown>) => typeof r.roomNumber === 'string' && r.roomNumber.toLowerCase().includes(keyword));
result = result.filter(
(r: Record<string, unknown>) =>
typeof r.roomNumber === 'string' && r.roomNumber.toLowerCase().includes(keyword),
);
}
if (filterBuilding) result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding);
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
if (filterBuilding)
result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding);
if (filterStatus)
result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
if (filterRentalCategory) {
result = result.filter((r: Record<string, unknown>) => r.rentalCategory === filterRentalCategory);
result = result.filter(
(r: Record<string, unknown>) => r.rentalCategory === filterRentalCategory,
);
}
return result;
}, [data, searchText, filterBuilding, filterStatus, filterRentalCategory]);
@@ -221,8 +233,11 @@ const RoomsPage: React.FC = () => {
bedForm.resetFields();
setBedEditing(null);
fetchBeds(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '操作失败'); }
finally { setSavingBed(false); }
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSavingBed(false);
}
};
const handleDeleteBed = async (id: number) => {
@@ -230,7 +245,9 @@ const RoomsPage: React.FC = () => {
await api.delete(`/rooms/${drawerRoom.id}/beds/${id}`);
message.success('已归档');
fetchBeds(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '归档失败'); }
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const handleBatchBeds = async (count: number) => {
@@ -238,7 +255,9 @@ const RoomsPage: React.FC = () => {
await api.post(`/rooms/${drawerRoom.id}/beds/batch`, { count });
message.success(`已生成 ${count} 张床位`);
fetchBeds(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '批量生成失败'); }
} catch (e: any) {
message.error(e?.message || '批量生成失败');
}
};
const handleSaveLocker = async () => {
@@ -255,8 +274,11 @@ const RoomsPage: React.FC = () => {
lockerForm.resetFields();
setLockerEditing(null);
fetchLockers(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '操作失败'); }
finally { setSavingLocker(false); }
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSavingLocker(false);
}
};
const handleDeleteLocker = async (id: number) => {
@@ -264,7 +286,9 @@ const RoomsPage: React.FC = () => {
await api.delete(`/rooms/${drawerRoom.id}/lockers/${id}`);
message.success('已归档');
fetchLockers(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '归档失败'); }
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const handleBatchLockers = async (count: number) => {
@@ -272,7 +296,9 @@ const RoomsPage: React.FC = () => {
await api.post(`/rooms/${drawerRoom.id}/lockers/batch`, { count });
message.success(`已生成 ${count} 个柜子`);
fetchLockers(drawerRoom.id);
} catch (e: any) { message.error(e?.message || '批量生成失败'); }
} catch (e: any) {
message.error(e?.message || '批量生成失败');
}
};
const handleArchive = async (id: number) => {
@@ -304,109 +330,126 @@ const RoomsPage: React.FC = () => {
downloadBlob('/rooms/export' + params, '房间列表.xlsx').catch(() => message.error('导出失败'));
};
const columns = useMemo(() => [
{
title: '房间号',
dataIndex: 'roomNumber',
width: 100,
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
},
{ title: '楼栋', dataIndex: 'building', width: 80 },
{ title: '楼层', dataIndex: 'floor', width: 80 },
{ title: '类型', dataIndex: 'roomType', width: 90, render: (v: any) => v || '-' },
{
title: '租赁类型',
dataIndex: 'rentalCategory',
width: 100,
render: (v: string) => {
if (v === 'long') return <Tag color="blue"></Tag>;
if (v === 'short') return <Tag color="green"></Tag>;
return '-';
const columns = useMemo(
() => [
{
title: '房间号',
dataIndex: 'roomNumber',
width: 100,
sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber),
},
},
{
title: '月租金',
dataIndex: 'monthlyRate',
width: 100,
render: (v: number) => (v ? `¥${v}` : '-'),
},
{ title: '额定人数', dataIndex: 'capacity', width: 80 },
{
title: '当前入住',
width: 80,
render: (_: any, r: any) =>
r.status === 'archived' ? (
<Tag color="#999">-</Tag>
) : (
<Badge
count={r.currentCount}
showZero
overflowCount={99}
style={{ backgroundColor: r.currentCount >= r.capacity ? '#ff4d4f' : '#52c41a' }}
/>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '操作',
width: 220,
render: (_: unknown, record: unknown) => {
const r = record as { status?: string; id: number };
return (
<Space>
{r.status === 'archived' ? (
<Popconfirm
title="确定恢复此宿舍?"
onConfirm={() => handleRestore(r.id)}
>
<PermissionButton permission="room:edit" size="small" icon={<UndoOutlined />} type="link">
</PermissionButton>
</Popconfirm>
) : (
<>
<PermissionButton
permission="room:view"
size="small"
type="link"
onClick={async () => {
const rec = record as { id: number };
setDrawerRoom(record);
setDrawerOpen(true);
await Promise.all([fetchBeds(rec.id), fetchLockers(rec.id)]);
}}
>
</PermissionButton>
<PermissionButton
permission="room:edit"
size="small"
onClick={() => {
const rec = record as { id: number };
setEditing(rec);
form.setFieldsValue(rec);
setModalOpen(true);
}}
>
</PermissionButton>
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
<PermissionButton permission="room:delete" size="small" icon={<InboxOutlined />}>
{ title: '楼栋', dataIndex: 'building', width: 80 },
{ title: '楼层', dataIndex: 'floor', width: 80 },
{ title: '类型', dataIndex: 'roomType', width: 90, render: (v: any) => v || '-' },
{
title: '租赁类型',
dataIndex: 'rentalCategory',
width: 100,
render: (v: string) => {
if (v === 'long') return <Tag color="blue"></Tag>;
if (v === 'short') return <Tag color="green"></Tag>;
return '-';
},
},
{
title: '月租金',
dataIndex: 'monthlyRate',
width: 100,
render: (v: number) => (v ? `¥${v}` : '-'),
},
{ title: '额定人数', dataIndex: 'capacity', width: 80 },
{
title: '当前入住',
width: 80,
render: (_: any, r: any) =>
r.status === 'archived' ? (
<Tag color="#999">-</Tag>
) : (
<Badge
count={r.currentCount}
showZero
overflowCount={99}
style={{ backgroundColor: r.currentCount >= r.capacity ? '#ff4d4f' : '#52c41a' }}
/>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '操作',
width: 220,
render: (_: unknown, record: unknown) => {
const r = record as { status?: string; id: number };
return (
<Space>
{r.status === 'archived' ? (
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
<PermissionButton
permission="room:edit"
size="small"
icon={<UndoOutlined />}
type="link"
>
</PermissionButton>
</Popconfirm>
</>
)}
</Space>
);
) : (
<>
<PermissionButton
permission="room:view"
size="small"
type="link"
onClick={async () => {
const rec = record as { id: number };
setDrawerRoom(record);
setDrawerOpen(true);
await Promise.all([fetchBeds(rec.id), fetchLockers(rec.id)]);
}}
>
</PermissionButton>
<PermissionButton
permission="room:edit"
size="small"
onClick={() => {
const rec = record as { id: number };
setEditing(rec);
form.setFieldsValue(rec);
setModalOpen(true);
}}
>
</PermissionButton>
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
<PermissionButton
permission="room:delete"
size="small"
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</>
)}
</Space>
);
},
},
},
], [showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, fetchBeds, fetchLockers]);
],
[
showArchived,
buildings,
handleBatchDelete,
handleRestore,
handleArchive,
fetchBeds,
fetchLockers,
],
);
return (
<div>
@@ -427,8 +470,18 @@ const RoomsPage: React.FC = () => {
onChange={(v) => setFilterBuilding(v)}
options={buildings.map((b) => ({ value: b, label: b }))}
/>
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus}
options={[{value:'available',label:'可入住'},{value:'full',label:'已满'},{value:'maintenance',label:'维护中'}]} />
<Select
placeholder="状态"
allowClear
style={{ width: 110 }}
value={filterStatus}
onChange={setFilterStatus}
options={[
{ value: 'available', label: '可入住' },
{ value: 'full', label: '已满' },
{ value: 'maintenance', label: '维护中' },
]}
/>
<Select
placeholder="租赁类型"
allowClear
@@ -612,7 +665,10 @@ const RoomsPage: React.FC = () => {
<Drawer
title={`${drawerRoom?.roomNumber} 房间详情`}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDrawerRoom(null); }}
onClose={() => {
setDrawerOpen(false);
setDrawerRoom(null);
}}
width={640}
destroyOnClose
>
@@ -624,14 +680,40 @@ const RoomsPage: React.FC = () => {
label: '基本信息',
children: drawerRoom && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div><strong></strong>{drawerRoom.roomNumber}</div>
<div><strong></strong>{drawerRoom.building || '-'}</div>
<div><strong></strong>{drawerRoom.floor ?? '-'}</div>
<div><strong></strong>{drawerRoom.roomType || '-'}</div>
<div><strong></strong>{drawerRoom.capacity}</div>
<div><strong></strong>{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}</div>
<div><strong></strong>{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}</div>
<div><strong></strong><Tag color={statusMap[drawerRoom.status]?.color}>{statusMap[drawerRoom.status]?.text}</Tag></div>
<div>
<strong></strong>
{drawerRoom.roomNumber}
</div>
<div>
<strong></strong>
{drawerRoom.building || '-'}
</div>
<div>
<strong></strong>
{drawerRoom.floor ?? '-'}
</div>
<div>
<strong></strong>
{drawerRoom.roomType || '-'}
</div>
<div>
<strong></strong>
{drawerRoom.capacity}
</div>
<div>
<strong></strong>
{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
</div>
<div>
<strong></strong>
{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
</div>
<div>
<strong></strong>
<Tag color={statusMap[drawerRoom.status]?.color}>
{statusMap[drawerRoom.status]?.text}
</Tag>
</div>
</div>
),
},
@@ -646,25 +728,48 @@ const RoomsPage: React.FC = () => {
size="small"
icon={<PlusOutlined />}
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
onClick={() => {
setBedEditing(null);
bedForm.resetFields();
setBedModalOpen(true);
}}
>
</Button>
<Popconfirm
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
description={
remainingBedSlots > 0
? <InputNumber min={1} max={remainingBedSlots} defaultValue={defaultBatchBedCount} id="batch-bed-count" style={{ width: 80 }} />
: '如需增加床位,请先调整宿舍额定人数'
remainingBedSlots > 0 ? (
<InputNumber
min={1}
max={remainingBedSlots}
defaultValue={defaultBatchBedCount}
id="batch-bed-count"
style={{ width: 80 }}
/>
) : (
'如需增加床位,请先调整宿舍额定人数'
)
}
onConfirm={() => {
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
handleBatchBeds(input ? parseInt(input.value) || defaultBatchBedCount : defaultBatchBedCount);
const input = document.getElementById(
'batch-bed-count',
) as HTMLInputElement;
handleBatchBeds(
input
? parseInt(input.value) || defaultBatchBedCount
: defaultBatchBedCount,
);
}}
okText="生成"
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
>
<Button size="small" disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}></Button>
<Button
size="small"
disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0}
>
</Button>
</Popconfirm>
</div>
<Table
@@ -675,7 +780,9 @@ const RoomsPage: React.FC = () => {
columns={[
{ title: '编号', dataIndex: 'bedNumber', width: 80 },
{
title: '状态', dataIndex: 'status', width: 80,
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => {
const map: Record<string, { text: string; color: string }> = {
available: { text: '空闲', color: 'green' },
@@ -687,7 +794,8 @@ const RoomsPage: React.FC = () => {
},
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
{
title: '操作', width: 120,
title: '操作',
width: 120,
render: (_: any, r: any) => (
<Space size="small">
<PermissionButton
@@ -695,12 +803,19 @@ const RoomsPage: React.FC = () => {
size="small"
type="link"
disabled={drawerRoom?.status === 'archived'}
onClick={() => { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }}
onClick={() => {
setBedEditing(r);
bedForm.setFieldsValue(r);
setBedModalOpen(true);
}}
>
</PermissionButton>
{r.status !== 'occupied' && (
<Popconfirm title="确定归档?" onConfirm={() => handleDeleteBed(r.id)}>
<Popconfirm
title="确定归档?"
onConfirm={() => handleDeleteBed(r.id)}
>
<PermissionButton
permission="room:edit"
size="small"
@@ -731,23 +846,37 @@ const RoomsPage: React.FC = () => {
size="small"
icon={<PlusOutlined />}
disabled={drawerRoom?.status === 'archived'}
onClick={() => { setLockerEditing(null); lockerForm.resetFields(); setLockerModalOpen(true); }}
onClick={() => {
setLockerEditing(null);
lockerForm.resetFields();
setLockerModalOpen(true);
}}
>
</Button>
<Popconfirm
title="批量生成柜子"
description={
<InputNumber min={1} max={20} defaultValue={4} id="batch-locker-count" style={{ width: 80 }} />
<InputNumber
min={1}
max={20}
defaultValue={4}
id="batch-locker-count"
style={{ width: 80 }}
/>
}
onConfirm={() => {
const input = document.getElementById('batch-locker-count') as HTMLInputElement;
const input = document.getElementById(
'batch-locker-count',
) as HTMLInputElement;
handleBatchLockers(input ? parseInt(input.value) || 4 : 4);
}}
okText="生成"
disabled={drawerRoom?.status === 'archived'}
>
<Button size="small" disabled={drawerRoom?.status === 'archived'}></Button>
<Button size="small" disabled={drawerRoom?.status === 'archived'}>
</Button>
</Popconfirm>
</div>
<Table
@@ -758,7 +887,9 @@ const RoomsPage: React.FC = () => {
columns={[
{ title: '编号', dataIndex: 'lockerNumber', width: 80 },
{
title: '状态', dataIndex: 'status', width: 80,
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => {
const map: Record<string, { text: string; color: string }> = {
available: { text: '空闲', color: 'green' },
@@ -770,7 +901,8 @@ const RoomsPage: React.FC = () => {
},
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
{
title: '操作', width: 120,
title: '操作',
width: 120,
render: (_: any, r: any) => (
<Space size="small">
<PermissionButton
@@ -778,12 +910,19 @@ const RoomsPage: React.FC = () => {
size="small"
type="link"
disabled={drawerRoom?.status === 'archived'}
onClick={() => { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }}
onClick={() => {
setLockerEditing(r);
lockerForm.setFieldsValue(r);
setLockerModalOpen(true);
}}
>
</PermissionButton>
{r.status !== 'occupied' && (
<Popconfirm title="确定归档?" onConfirm={() => handleDeleteLocker(r.id)}>
<Popconfirm
title="确定归档?"
onConfirm={() => handleDeleteLocker(r.id)}
>
<PermissionButton
permission="room:edit"
size="small"
@@ -811,7 +950,10 @@ const RoomsPage: React.FC = () => {
title={bedEditing ? '编辑床位' : '添加床位'}
open={bedModalOpen}
onOk={handleSaveBed}
onCancel={() => { setBedModalOpen(false); setBedEditing(null); }}
onCancel={() => {
setBedModalOpen(false);
setBedEditing(null);
}}
confirmLoading={savingBed}
okText="保存"
>
@@ -838,7 +980,10 @@ const RoomsPage: React.FC = () => {
title={lockerEditing ? '编辑柜子' : '添加柜子'}
open={lockerModalOpen}
onOk={handleSaveLocker}
onCancel={() => { setLockerModalOpen(false); setLockerEditing(null); }}
onCancel={() => {
setLockerModalOpen(false);
setLockerEditing(null);
}}
confirmLoading={savingLocker}
okText="保存"
>