feat: 增加宿舍查寝功能
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
DatePicker,
|
||||
Alert,
|
||||
Button,
|
||||
Switch,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
@@ -21,10 +23,15 @@ import {
|
||||
BankOutlined,
|
||||
HistoryOutlined,
|
||||
ShopOutlined,
|
||||
CheckCircleOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
|
||||
|
||||
function getCardStyle(room: any): React.CSSProperties {
|
||||
let base: React.CSSProperties;
|
||||
@@ -84,6 +91,9 @@ const RoomVisualPage: React.FC = () => {
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
|
||||
const [detailRoom, setDetailRoom] = useState<any>(null);
|
||||
const [asOf, setAsOf] = useState<Dayjs | null>(null);
|
||||
const [presentOccupancyIds, setPresentOccupancyIds] = useState<number[]>([]);
|
||||
const [inspectionSaving, setInspectionSaving] = useState(false);
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day');
|
||||
|
||||
@@ -104,6 +114,42 @@ const RoomVisualPage: React.FC = () => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailRoom) {
|
||||
setPresentOccupancyIds([]);
|
||||
return;
|
||||
}
|
||||
setPresentOccupancyIds(
|
||||
getInitialPresentOccupancyIds(
|
||||
detailRoom.occupants || [],
|
||||
detailRoom.inspection?.submitted === true,
|
||||
),
|
||||
);
|
||||
}, [detailRoom]);
|
||||
|
||||
const inspectionDate = (asOf || dayjs()).format('YYYY-MM-DD');
|
||||
|
||||
const submitInspection = async () => {
|
||||
if (!detailRoom || isHistorical) return;
|
||||
setInspectionSaving(true);
|
||||
try {
|
||||
await api.put(`/rooms/${detailRoom.id}/inspections/${inspectionDate}`, {
|
||||
presentOccupancyIds,
|
||||
});
|
||||
message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交');
|
||||
const params = isHistorical ? { asOf: inspectionDate } : undefined;
|
||||
const res: any = await api.get('/rooms/visual', { params });
|
||||
setData(res);
|
||||
const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id);
|
||||
if (updatedRoom) setDetailRoom(updatedRoom);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '查寝提交失败');
|
||||
} finally {
|
||||
setInspectionSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||
|
||||
const rooms = data.rooms.filter((r: any) => {
|
||||
@@ -345,6 +391,17 @@ const RoomVisualPage: React.FC = () => {
|
||||
{room.occupants.length > 4 && <Tag>+{room.occupants.length - 4}</Tag>}
|
||||
</div>
|
||||
)}
|
||||
{room.occupants.length > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{room.inspection?.submitted ? (
|
||||
<Tag color={room.inspection.source === 'automatic' ? 'orange' : 'green'}>
|
||||
{room.inspection.source === 'automatic' ? '自动补记' : '已查寝'}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>未查寝</Tag>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
@@ -388,9 +445,26 @@ const RoomVisualPage: React.FC = () => {
|
||||
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
|
||||
{detailRoom.occupants.length > 0 ? (
|
||||
<div>
|
||||
<h4 style={{ marginBottom: 8 }}>当前住户</h4>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0 }}>床位查寝</h4>
|
||||
{detailRoom.inspection?.submitted && (
|
||||
<Tag color={detailRoom.inspection.source === 'automatic' ? 'orange' : 'green'}>
|
||||
{detailRoom.inspection.source === 'automatic' ? '自动补记' : '已提交'} ·{' '}
|
||||
{detailRoom.inspection.inspectorName}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{detailRoom.occupants.map((o: any) => (
|
||||
<Card key={o.studentId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
|
||||
<Card key={o.occupancyId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -407,10 +481,44 @@ const RoomVisualPage: React.FC = () => {
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Tag color="blue">{o.days} 天</Tag>
|
||||
{isHistorical ? (
|
||||
<Tag
|
||||
color={
|
||||
o.inspectionStatus === 'present'
|
||||
? 'green'
|
||||
: o.inspectionStatus === 'absent'
|
||||
? 'red'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{o.inspectionStatus === 'present'
|
||||
? '在寝'
|
||||
: o.inspectionStatus === 'absent'
|
||||
? '缺勤'
|
||||
: '无记录'}
|
||||
</Tag>
|
||||
) : (
|
||||
<Space size="small">
|
||||
<span style={{ color: '#86868b', fontSize: 12 }}>
|
||||
{presentOccupancyIds.includes(o.occupancyId) ? '在寝' : '缺勤'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={presentOccupancyIds.includes(o.occupancyId)}
|
||||
disabled={!hasPermission('room:inspect')}
|
||||
checkedChildren="在寝"
|
||||
unCheckedChildren="缺勤"
|
||||
onChange={(checked) =>
|
||||
setPresentOccupancyIds((current) =>
|
||||
togglePresentOccupancy(current, o.occupancyId, checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: '#86868b', fontSize: 12, marginTop: 4 }}>
|
||||
<CalendarOutlined style={{ marginRight: 4 }} />
|
||||
床位:{o.bedNumber || '未分配'} |{' '}
|
||||
入住:{o.checkInDate} | 计费起:{o.billingStartDate}
|
||||
{o.supervisor && (
|
||||
<span style={{ marginLeft: 8 }}>负责人:{o.supervisor}</span>
|
||||
@@ -418,6 +526,37 @@ const RoomVisualPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{!isHistorical && hasPermission('room:inspect') && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() =>
|
||||
setPresentOccupancyIds(
|
||||
detailRoom.occupants.map((occupant: any) => occupant.occupancyId),
|
||||
)
|
||||
}
|
||||
>
|
||||
本宿舍一键打卡
|
||||
</Button>
|
||||
<PermissionButton
|
||||
permission="room:inspect"
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={inspectionSaving}
|
||||
onClick={submitInspection}
|
||||
>
|
||||
提交查寝
|
||||
</PermissionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
|
||||
|
||||
describe('room inspection state', () => {
|
||||
const occupants = [
|
||||
{ occupancyId: 11, inspectionStatus: 'present' as const },
|
||||
{ occupancyId: 12, inspectionStatus: 'absent' as const },
|
||||
];
|
||||
|
||||
it('starts every occupant unchecked before the room is submitted', () => {
|
||||
expect(getInitialPresentOccupancyIds(occupants, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores the saved present occupants after submission', () => {
|
||||
expect(getInitialPresentOccupancyIds(occupants, true)).toEqual([11]);
|
||||
});
|
||||
|
||||
it('adds and removes a single occupant without changing the others', () => {
|
||||
expect(togglePresentOccupancy([11], 12, true).sort()).toEqual([11, 12]);
|
||||
expect(togglePresentOccupancy([11, 12], 11, false)).toEqual([12]);
|
||||
});
|
||||
});
|
||||
25
apps/admin/src/pages/RoomVisual/inspection-state.ts
Normal file
25
apps/admin/src/pages/RoomVisual/inspection-state.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface InspectionOccupant {
|
||||
occupancyId: number;
|
||||
inspectionStatus?: 'present' | 'absent' | null;
|
||||
}
|
||||
|
||||
export function getInitialPresentOccupancyIds(
|
||||
occupants: InspectionOccupant[],
|
||||
submitted: boolean,
|
||||
): number[] {
|
||||
if (!submitted) return [];
|
||||
return occupants
|
||||
.filter((occupant) => occupant.inspectionStatus === 'present')
|
||||
.map((occupant) => occupant.occupancyId);
|
||||
}
|
||||
|
||||
export function togglePresentOccupancy(
|
||||
currentIds: number[],
|
||||
occupancyId: number,
|
||||
present: boolean,
|
||||
): number[] {
|
||||
const next = new Set(currentIds);
|
||||
if (present) next.add(occupancyId);
|
||||
else next.delete(occupancyId);
|
||||
return [...next];
|
||||
}
|
||||
Reference in New Issue
Block a user