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

@@ -60,8 +60,16 @@ const ClassroomsPage: React.FC = () => {
const filteredData = useMemo(() => {
let result = data;
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record<string, unknown>) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
if (searchText) {
const s = searchText.toLowerCase();
result = result.filter(
(d: Record<string, unknown>) =>
(typeof d.name === 'string' && d.name.toLowerCase().includes(s)) ||
(typeof d.building === 'string' && d.building.toLowerCase().includes(s)),
);
}
if (filterStatus)
result = result.filter((d: Record<string, unknown>) => d.effectiveStatus === filterStatus);
return result;
}, [data, searchText, filterStatus]);
@@ -140,72 +148,98 @@ const ClassroomsPage: React.FC = () => {
.catch(() => message.error('下载失败'));
};
const columns = useMemo(() => [
{
title: '教室名', width: 120,
dataIndex: 'name',
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
},
{ title: '楼栋', dataIndex: 'building', width: 80 },
{ title: '楼层', dataIndex: 'floor', width: 80 },
{
title: '类型', width: 90,
dataIndex: 'roomType',
render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>,
},
{ title: '容量', dataIndex: 'capacity', width: 80 },
{
title: '状态', width: 100,
dataIndex: 'status',
render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => {
const effectiveStatus = record.effectiveStatus || record.status;
return (
<Tooltip title={record.currentUsage ? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})` : undefined}>
<Tag color={statusMap[effectiveStatus]?.color}>{statusMap[effectiveStatus]?.text || effectiveStatus}</Tag>
</Tooltip>
);
const columns = useMemo(
() => [
{
title: '教室名',
width: 120,
dataIndex: 'name',
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
},
},
{
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<Popconfirm title="确定恢复此教室?" onConfirm={() => handleRestore(record.id)}>
<PermissionButton permission="classroom:edit" size="small" icon={<UndoOutlined />} type="link">
</PermissionButton>
</Popconfirm>
) : (
<>
<PermissionButton
permission="classroom:edit"
size="small"
onClick={() => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</PermissionButton>
<Popconfirm
title="归档后数据保留,可随时恢复。存在进行中的租赁将无法归档。"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<PermissionButton permission="classroom:delete" size="small" icon={<InboxOutlined />}>
{ title: '楼栋', dataIndex: 'building', width: 80 },
{ title: '楼层', dataIndex: 'floor', width: 80 },
{
title: '类型',
width: 90,
dataIndex: 'roomType',
render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>,
},
{ title: '容量', dataIndex: 'capacity', width: 80 },
{
title: '状态',
width: 100,
dataIndex: 'status',
render: (
_s: string,
record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null },
) => {
const effectiveStatus = record.effectiveStatus || record.status;
return (
<Tooltip
title={
record.currentUsage
? `${record.currentUsage.title} (${record.currentUsage.startTime}-${record.currentUsage.endTime})`
: undefined
}
>
<Tag color={statusMap[effectiveStatus]?.color}>
{statusMap[effectiveStatus]?.text || effectiveStatus}
</Tag>
</Tooltip>
);
},
},
{
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<Popconfirm title="确定恢复此教室?" onConfirm={() => handleRestore(record.id)}>
<PermissionButton
permission="classroom:edit"
size="small"
icon={<UndoOutlined />}
type="link"
>
</PermissionButton>
</Popconfirm>
</>
)}
</Space>
),
},
], []);
) : (
<>
<PermissionButton
permission="classroom:edit"
size="small"
onClick={() => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</PermissionButton>
<Popconfirm
title="归档后数据保留,可随时恢复。存在进行中的租赁将无法归档。"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<PermissionButton
permission="classroom:delete"
size="small"
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</>
)}
</Space>
),
},
],
[],
);
return (
<div>
@@ -228,7 +262,20 @@ const ClassroomsPage: React.FC = () => {
if (!e.target.value) setSearchText('');
}}
/>
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} options={[{value:'available',label:'可用'},{value:'in_use',label:'使用中'},{value:'reserved',label:'已预留'},{value:'maintenance',label:'维护中'},{value:'archived',label:'已归档'}]} />
<Select
placeholder="状态"
allowClear
style={{ width: 110 }}
value={filterStatus}
onChange={setFilterStatus}
options={[
{ value: 'available', label: '可用' },
{ value: 'in_use', label: '使用中' },
{ value: 'reserved', label: '已预留' },
{ value: 'maintenance', label: '维护中' },
{ value: 'archived', label: '已归档' },
]}
/>
<Button
type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)}
@@ -249,7 +296,26 @@ const ClassroomsPage: React.FC = () => {
>
</PermissionButton>
<PermissionButton permission="classroom:view" icon={<DownloadOutlined />} onClick={() => { const baseURL = '/api'; const token = localStorage.getItem('token'); fetch(`${baseURL}/classrooms/export`, { headers: { Authorization: `Bearer ${token}` } }).then(r => r.blob()).then(b => { const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = '教室使用报表.xlsx'; a.click(); }); }}></PermissionButton>
<PermissionButton
permission="classroom:view"
icon={<DownloadOutlined />}
onClick={() => {
const baseURL = '/api';
const token = localStorage.getItem('token');
fetch(`${baseURL}/classrooms/export`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.blob())
.then((b) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = '教室使用报表.xlsx';
a.click();
});
}}
>
</PermissionButton>
<Upload
accept=".xlsx,.xls"
showUploadList={false}