forked from wangziqi/gongxue-base
571 lines
21 KiB
TypeScript
571 lines
21 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, DatePicker, Alert, Button, Switch, Space, Skeleton} from 'antd';
|
|
import {
|
|
HomeOutlined,
|
|
UserOutlined,
|
|
CalendarOutlined,
|
|
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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
import { usePermission } from '../../hooks/usePermission';
|
|
import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state';
|
|
import { getErrorMessage } from '../../utils/error';
|
|
|
|
function getCardStyle(room: any): React.CSSProperties {
|
|
let base: React.CSSProperties;
|
|
if (room.status === 'maintenance') base = { background: '#f5f5f5', borderColor: '#d9d9d9' };
|
|
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
|
|
else if (room.currentCount >= room.capacity)
|
|
base = { background: '#fff2f0', borderColor: '#ffccc7' };
|
|
else base = { background: '#e6f4ff', borderColor: '#91caff' };
|
|
if (room.organizationColor) {
|
|
return {
|
|
...base,
|
|
background: `color-mix(in srgb, ${room.organizationColor} 15%, ${base.background || '#fff'} 85%)`,
|
|
};
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function getStatusLabel(room: any) {
|
|
if (room.status === 'maintenance') return <Tag color="default">维修中</Tag>;
|
|
if (room.currentCount === 0) return <Tag color="success">空闲</Tag>;
|
|
if (room.currentCount >= room.capacity) return <Tag color="error">满员</Tag>;
|
|
return <Tag color="processing">部分入住</Tag>;
|
|
}
|
|
|
|
function getOrganizationTags(occupants: any[]) {
|
|
const organizationList = [
|
|
...new Map(
|
|
occupants
|
|
.filter((o: any) => o.organizationName)
|
|
.map((o: any) => [
|
|
o.organizationId,
|
|
{ name: o.organizationName, color: o.organizationColor },
|
|
]),
|
|
).values(),
|
|
] as { name: string; color: string | null }[];
|
|
if (organizationList.length === 0) return null;
|
|
return (
|
|
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
|
|
{organizationList.map((t) => (
|
|
<Tag
|
|
key={t.name}
|
|
color={t.color || 'gold'}
|
|
style={{ fontSize: 12, marginBottom: 4, maxWidth: '100%' }}
|
|
icon={<ShopOutlined />}
|
|
>
|
|
{t.name}
|
|
</Tag>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const RoomVisualPage: React.FC = () => {
|
|
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
|
|
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');
|
|
|
|
const queryClient = useQueryClient();
|
|
const { data, isLoading, isFetching, isError } = useQuery<any>({
|
|
queryKey: ['rooms', 'visual', isHistorical, asOf],
|
|
queryFn: async () => {
|
|
const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
|
|
return await api.get('/rooms/visual', { params });
|
|
},
|
|
});
|
|
const loading = isLoading || isFetching;
|
|
|
|
const openRoomDetail = (room: any) => {
|
|
setDetailRoom(room);
|
|
setPresentOccupancyIds(
|
|
getInitialPresentOccupancyIds(room.occupants || [], room.inspection?.submitted === true),
|
|
);
|
|
};
|
|
|
|
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 });
|
|
queryClient.setQueryData(['rooms', 'visual', isHistorical, asOf], res);
|
|
const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id);
|
|
if (updatedRoom) setDetailRoom(updatedRoom);
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '查寝提交失败'));
|
|
} finally {
|
|
setInspectionSaving(false);
|
|
}
|
|
};
|
|
|
|
if (isError && !data) {
|
|
return (
|
|
<Space direction="vertical" align="center" style={{ display: 'flex', padding: '100px 0' }}>
|
|
<Alert type="error" showIcon message="宿舍数据加载失败" description="请检查网络后重试。" />
|
|
<Button
|
|
type="primary"
|
|
loading={loading}
|
|
onClick={() =>
|
|
void queryClient.refetchQueries({ queryKey: ['rooms', 'visual', isHistorical, asOf] })
|
|
}
|
|
>
|
|
重试
|
|
</Button>
|
|
</Space>
|
|
);
|
|
}
|
|
|
|
if (!data)
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<Skeleton active paragraph={{ rows: 12 }} />
|
|
</div>
|
|
);
|
|
|
|
const rooms = data.rooms.filter((r: any) => {
|
|
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
|
|
if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization))
|
|
return false;
|
|
return true;
|
|
});
|
|
|
|
const totalRooms = rooms.length;
|
|
const emptyRooms = rooms.filter(
|
|
(r: any) => r.currentCount === 0 && r.status !== 'maintenance',
|
|
).length;
|
|
const totalBeds = rooms.reduce((sum: number, r: any) => sum + (r.totalBeds || 0), 0);
|
|
const occupiedBeds = rooms.reduce((sum: number, r: any) => sum + (r.occupiedBeds || 0), 0);
|
|
const availableBedsCount = totalBeds - occupiedBeds;
|
|
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
|
|
|
|
/* getCardStyle, getStatusLabel, getOrganizationTags are now standalone functions outside the component */
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
style={{
|
|
marginBottom: 16,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0 }}>宿舍总览</h2>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
<DatePicker
|
|
value={asOf}
|
|
onChange={setAsOf}
|
|
allowClear
|
|
placeholder="查看历史日期"
|
|
suffixIcon={<HistoryOutlined />}
|
|
disabledDate={(d) => d && d.isAfter(dayjs(), 'day')}
|
|
style={{ width: 180 }}
|
|
/>
|
|
<Select
|
|
value={selectedBuilding}
|
|
onChange={setSelectedBuilding}
|
|
style={{ width: 160 }}
|
|
options={[
|
|
{ value: 'all', label: '全部楼栋' },
|
|
...data.buildings.map((b: string) => ({ value: b, label: b })),
|
|
]}
|
|
/>
|
|
<Select
|
|
value={selectedOrganization}
|
|
onChange={setSelectedOrganization}
|
|
style={{ width: 180 }}
|
|
options={[
|
|
{ value: 'all', label: '全部机构' },
|
|
...(data.organizations || []).map((t: any) => ({
|
|
value: t.id,
|
|
label: (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
{t.color && (
|
|
<span
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: '50%',
|
|
backgroundColor: t.color,
|
|
display: 'inline-block',
|
|
}}
|
|
/>
|
|
)}
|
|
{t.name}
|
|
</span>
|
|
),
|
|
})),
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isHistorical && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
icon={<HistoryOutlined />}
|
|
style={{ marginBottom: 16 }}
|
|
title={`正在查看 ${asOf ? asOf.format('YYYY年M月D日') : ''} 的历史入住情况(含当日已归档房间),非实时数据`}
|
|
action={
|
|
<Button size="small" type="link" onClick={() => setAsOf(null)}>
|
|
返回今天
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{loading && <Spin style={{ display: 'block', margin: '8px auto 16px' }} />}
|
|
|
|
{/* 统计栏 */}
|
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic title="宿舍总数" value={totalRooms} prefix={<HomeOutlined />} />
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="空闲房间"
|
|
value={emptyRooms}
|
|
styles={{ value: { color: '#34C759' } }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="可安排床位"
|
|
value={availableBedsCount}
|
|
styles={{ value: { color: '#007AFF' } }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="满员房间"
|
|
value={fullRooms}
|
|
styles={{ value: { color: '#FF3B30' } }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* 房态网格 */}
|
|
<Row gutter={[12, 12]}>
|
|
{rooms.map((room: any) => (
|
|
<Col xs={12} sm={8} md={6} lg={4} key={room.id}>
|
|
<Card
|
|
size="small"
|
|
hoverable
|
|
style={{
|
|
...getCardStyle(room),
|
|
borderRadius: 12,
|
|
borderWidth: 2,
|
|
cursor: 'pointer',
|
|
height: '100%',
|
|
}}
|
|
onClick={() => openRoomDetail(room)}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
fontSize: 16,
|
|
fontWeight: 600,
|
|
color: '#1d1d1f',
|
|
}}
|
|
>
|
|
{room.organizationColor && (
|
|
<span
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: '50%',
|
|
backgroundColor: room.organizationColor,
|
|
display: 'inline-block',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
{room.roomNumber}
|
|
</span>
|
|
{getStatusLabel(room)}
|
|
</div>
|
|
<div style={{ color: '#86868b', fontSize: 12, marginBottom: 6 }}>
|
|
{room.building && <span>{room.building} </span>}
|
|
{room.floor && <span>{room.floor}F</span>}
|
|
</div>
|
|
{room.totalBeds > 0 && (
|
|
<div
|
|
style={{
|
|
fontSize: 12,
|
|
color: room.occupiedBeds >= room.totalBeds ? '#FF3B30' : '#34C759',
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
床位: {room.occupiedBeds}/{room.totalBeds}
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 8 }}>
|
|
<Badge
|
|
count={`${room.currentCount}/${room.capacity}`}
|
|
showZero
|
|
style={{
|
|
backgroundColor:
|
|
room.currentCount >= room.capacity
|
|
? '#FF3B30'
|
|
: room.currentCount > 0
|
|
? '#007AFF'
|
|
: '#34C759',
|
|
fontSize: 12,
|
|
}}
|
|
/>
|
|
</div>
|
|
{room.orgLabel && (
|
|
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
|
|
<Tag color="purple" style={{ fontSize: 12 }} icon={<BankOutlined />}>
|
|
{room.orgLabel}
|
|
</Tag>
|
|
</div>
|
|
)}
|
|
{getOrganizationTags(room.occupants)}
|
|
{room.occupants.length > 0 && (
|
|
<div
|
|
className="room-card-tag-wrapper"
|
|
style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}
|
|
>
|
|
{room.occupants.slice(0, 4).map((o: any) => (
|
|
<Tooltip key={o.studentId} title={`入住 ${o.days} 天 (${o.checkInDate} 起)`}>
|
|
<Tag
|
|
style={{ margin: '0 4px 4px 0', fontSize: 12, maxWidth: '100%' }}
|
|
icon={<UserOutlined />}
|
|
>
|
|
{o.studentName}
|
|
</Tag>
|
|
</Tooltip>
|
|
))}
|
|
{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>
|
|
))}
|
|
</Row>
|
|
|
|
{/* 详情弹窗 */}
|
|
<Modal
|
|
title={`宿舍 ${detailRoom?.roomNumber} 详情`}
|
|
open={!!detailRoom}
|
|
onCancel={() => setDetailRoom(null)}
|
|
footer={null}
|
|
width={500}
|
|
>
|
|
{detailRoom && (
|
|
<div>
|
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
|
<Col span={8}>
|
|
<Statistic title="额定人数" value={detailRoom.capacity} />
|
|
</Col>
|
|
<Col span={8}>
|
|
<Statistic title="当前入住" value={detailRoom.currentCount} />
|
|
</Col>
|
|
<Col span={8}>
|
|
<Statistic
|
|
title="剩余床位"
|
|
value={Math.max(0, detailRoom.capacity - detailRoom.currentCount)}
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>
|
|
位置:{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
|
|
</div>
|
|
|
|
{detailRoom.organizationColor && (
|
|
<div style={{ marginBottom: 8 }}>
|
|
<Tag color={detailRoom.organizationColor}>
|
|
{detailRoom.occupants[0]?.organizationName || '机构'}
|
|
</Tag>
|
|
</div>
|
|
)}
|
|
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
|
|
{detailRoom.occupants.length > 0 ? (
|
|
<div>
|
|
<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.occupancyId}
|
|
size="small"
|
|
style={{ marginBottom: 8, borderRadius: 8 }}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<div>
|
|
<UserOutlined style={{ marginRight: 6 }} />
|
|
<strong>{o.studentName}</strong>
|
|
{o.organization && (
|
|
<Tag color="purple" style={{ marginLeft: 6, fontSize: 12 }}>
|
|
{o.organization}
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
{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>
|
|
{hasPermission('room:inspect') ? (
|
|
<Switch
|
|
checked={presentOccupancyIds.includes(o.occupancyId)}
|
|
checkedChildren="在寝"
|
|
unCheckedChildren="缺勤"
|
|
onChange={(checked) =>
|
|
setPresentOccupancyIds((current) =>
|
|
togglePresentOccupancy(current, o.occupancyId, checked),
|
|
)
|
|
}
|
|
/>
|
|
) : null}
|
|
</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>
|
|
)}
|
|
</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' }}>
|
|
当前无住户,可安排入住
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RoomVisualPage;
|