MDM/mdm-front/src/pages/DevicePage/DevicePage.tsx

550 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
useNavigate,
useOutletContext,
useParams,
useSearchParams,
} from 'react-router-dom'
import { AnimatePresence, m } from 'framer-motion'
import { useQuery } from '@apollo/client/react'
import type { Device as PageDevice } from './types'
import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
import type {
Device as ApiDevice,
DeviceNetworkStatus,
DeviceTechnicalStatus,
GetPhoneGpsTrackData,
GetTelemetryData,
GetDevicePageData,
GetDevicePageVariables,
} from '../../entities/device/model/types'
import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard'
import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
import { DeviceActionsCard } from './components/DeviceActionsCard/DeviceActionsCard'
import { DevicePermissionsCard } from './components/DevicePermissionsCard/DevicePermissionsCard'
import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards'
import { DeviceAppsView } from './components/DeviceAppsView/DeviceAppsView'
import { pageSwapVariants, uiFadeTransition } from '../../shared/lib/motion'
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import './DevicePage.scss'
import { DevicePageSkeleton } from './components/DevicePageSkeleton/DevicePageSkeleton'
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
import {
preloadOnIdle,
useDeferredModalPayload,
useLazyMount,
} from '../../shared/lib/lazyMount'
import { isPresent } from '../../shared/lib/isPresent'
const loadDeviceHistoryModal = () =>
import('./components/DeviceHistoryModal/DeviceHistoryModal').then(
(module) => ({
default: module.DeviceHistoryModal,
}),
)
const DeviceHistoryModal = lazy(loadDeviceHistoryModal)
const loadDeviceMaintenanceModal = () =>
import('./components/DeviceMaintenanceModal/DeviceMaintenanceModal').then(
(module) => ({
default: module.DeviceMaintenanceModal,
}),
)
const DeviceMaintenanceModal = lazy(loadDeviceMaintenanceModal)
const loadDeviceMaintenanceHistoryModal = () =>
import(
'./components/DeviceMaintenanceHistoryModal/DeviceMaintenanceHistoryModal'
).then((module) => ({
default: module.DeviceMaintenanceHistoryModal,
}))
const DeviceMaintenanceHistoryModal = lazy(
loadDeviceMaintenanceHistoryModal,
)
function formatLocationDate(timestamp: number) {
if (!timestamp) return 'Нет данных'
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(timestamp))
}
function formatTechValue(value?: number | null) {
return typeof value === 'number' ? String(value) : undefined
}
function getLatestTelemetryItem(telemetry: GetTelemetryData['getTelemetry']) {
if (!telemetry.length) return null
return [...telemetry].sort((a, b) => b.date - a.date)[0]
}
function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) {
return track.filter(isPresent).sort((a, b) => a.date - b.date)
}
function getDeviceNetworkConnection(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'online'
if (status === 'Lost') return 'offlineDanger'
return 'offline'
}
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'В сети'
if (status === 'Lost') return 'Потерян'
return 'Не в сети'
}
function getDeviceCondition(status?: DeviceTechnicalStatus) {
if (status === 'InService') return 'service'
if (status === 'NeedsMaintenance') return 'inspection'
return 'ok'
}
function mapApiDeviceToPageDevice(
device: ApiDevice,
batteryLevel?: number,
gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [],
telemetry: GetTelemetryData['getTelemetry'] = [],
): PageDevice {
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
const policy = device.policy
const techState = device.techState
const bluetoothEnabled = isPhonePolicyOptionEnabled(
policy?.bluetooth ?? policy?.canUseBluetooth,
)
const gpsEnabled = isPhonePolicyOptionEnabled(policy?.GPS ?? policy?.canUseGPS)
const cameraEnabled = isPhonePolicyOptionEnabled(
policy?.camera ?? policy?.canUseCamera,
)
const simEnabled = isPhonePolicyOptionEnabled(policy?.sim ?? policy?.canUseSim)
const isLocked = policy?.locked ?? false
const currentGpsPoint =
sortedGpsTrack.length > 0
? sortedGpsTrack[sortedGpsTrack.length - 1]
: null
const routeGpsPoints =
sortedGpsTrack.length > 1
? sortedGpsTrack.slice(0, -1)
: []
const location = currentGpsPoint
? {
lat: currentGpsPoint.lat,
lng: currentGpsPoint.lng,
}
: device.lastLocation
? {
lat: device.lastLocation.lat,
lng: device.lastLocation.lng,
}
: undefined
const lastLocationAt = currentGpsPoint
? formatLocationDate(currentGpsPoint.date)
: device.lastLocation
? formatLocationDate(device.lastLocation.date)
: 'Нет данных'
return {
id: device.id,
model: 'АРМАФОН S3.3+',
factoryNumber: device.serial || 'Заводской номер не указан',
imei: device.imei || 'IMEI не указан',
imei2: device.imei2 || 'IMEI 2 не указан',
serialNumber: device.serial || undefined,
employee: device.org?.name ?? null,
organisation: device.org?.name,
organisationId: device.org?.id ?? device.orgId,
policy,
organisationPolicy: device.org?.policy ?? null,
condition: getDeviceCondition(techState?.status),
technicalStatus: techState?.status ?? 'Healthy',
malfunctions: techState?.malfunctions ?? [],
malfunctionComment: techState?.malfunctionComment,
connection: getDeviceNetworkConnection(device.networkStatus),
connectionText: getDeviceNetworkLabel(device.networkStatus),
workTime: null,
registeredAt: device.registerDate
? formatLocationDate(device.registerDate)
: undefined,
image: undefined,
location,
lastLocationAt,
lastLocationDate: device.lastLocation?.date,
route: routeGpsPoints.map((point) => ({
lat: point.lat,
lng: point.lng,
time: formatLocationDate(point.date),
})),
permissions: {
wifi: false,
bluetooth: bluetoothEnabled,
gps: gpsEnabled,
camera: cameraEnabled,
sim: simEnabled,
speaker: false,
locked: isLocked,
},
statusIcons: {
gps: gpsEnabled,
wifi: false,
bluetooth: bluetoothEnabled,
lock: isLocked,
camera: cameraEnabled,
sim: simEnabled,
sound: false,
kiosk: false,
},
battery: techState?.batteryLevel ?? batteryLevel,
batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined,
telemetry,
chargeCycles: formatTechValue(techState?.batteryCycles),
totalWorkTime: techState?.worktime ?? null,
mediumImpacts: formatTechValue(techState?.hits),
overheats: formatTechValue(techState?.overheats),
}
}
export function DevicePage() {
const { deviceId } = useParams()
const navigate = useNavigate()
const { setNavbarBreadcrumbs } =
useOutletContext<AppLayoutOutletContext>()
const [searchParams, setSearchParams] = useSearchParams()
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const shouldMountHistoryModal = useLazyMount(isHistoryOpen)
const [maintenanceMode, setMaintenanceMode] = useState<
'view' | 'manage' | null
>(null)
const renderMaintenanceMode = useDeferredModalPayload(maintenanceMode)
const shouldMountMaintenanceModal = useLazyMount(maintenanceMode !== null)
const [isMaintenanceHistoryOpen, setIsMaintenanceHistoryOpen] =
useState(false)
const shouldMountMaintenanceHistoryModal = useLazyMount(
isMaintenanceHistoryOpen,
)
const maintenanceHistoryTimerRef = useRef<number | null>(null)
const numericDeviceId = Number(deviceId)
const isAppsView = searchParams.get('view') === 'apps'
useEffect(() => {
return preloadOnIdle([
loadDeviceHistoryModal,
loadDeviceMaintenanceModal,
loadDeviceMaintenanceHistoryModal,
])
}, [])
useEffect(() => {
return () => {
if (maintenanceHistoryTimerRef.current !== null) {
window.clearTimeout(maintenanceHistoryTimerRef.current)
}
}
}, [])
const {
data,
loading,
error,
refetch,
startPolling,
stopPolling,
} = useQuery<
GetDevicePageData,
GetDevicePageVariables
>(GET_DEVICE_PAGE_QUERY, {
variables: {
id: numericDeviceId,
phoneId: String(numericDeviceId),
telemetryStartDate: 0,
gpsStartDate: 0,
},
skip: !numericDeviceId,
fetchPolicy: 'network-only',
pollInterval: 15000,
})
const handlePermissionsEditingChange = useCallback(
(isEditing: boolean) => {
if (isEditing) {
stopPolling()
return
}
if (numericDeviceId) {
startPolling(15000)
}
},
[numericDeviceId, startPolling, stopPolling],
)
const latestTelemetryItem = useMemo(() => {
return getLatestTelemetryItem(data?.getTelemetry ?? [])
}, [data])
const device = useMemo(() => {
if (!data?.getPhone) return null
return mapApiDeviceToPageDevice(
data.getPhone,
latestTelemetryItem?.batteryLevel ?? undefined,
data.getPhoneGpsTrack ?? [],
data.getTelemetry ?? [],
)
}, [data, latestTelemetryItem])
useEffect(() => {
if (!device) {
setNavbarBreadcrumbs([])
return
}
setNavbarBreadcrumbs([
{
label: device.factoryNumber,
to: isAppsView ? `/devices/${device.id}` : undefined,
},
...(isAppsView ? [{ label: 'Приложения' }] : []),
])
return () => {
setNavbarBreadcrumbs([])
}
}, [device, isAppsView, setNavbarBreadcrumbs])
const handleOpenApps = useCallback(() => {
const nextParams = new URLSearchParams(searchParams)
nextParams.set('view', 'apps')
setSearchParams(nextParams)
}, [searchParams, setSearchParams])
const handleCloseApps = useCallback(() => {
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('view')
setSearchParams(nextParams, {
replace: true,
})
}, [searchParams, setSearchParams])
const handleOpenHistory = useCallback(() => {
setIsHistoryOpen(true)
}, [])
const handleOpenMaintenanceDetails = useCallback(() => {
setMaintenanceMode('view')
}, [])
const handleManageMaintenance = useCallback(() => {
setMaintenanceMode('manage')
}, [])
const handleOpenMaintenanceHistory = useCallback(() => {
setMaintenanceMode(null)
if (maintenanceHistoryTimerRef.current !== null) {
window.clearTimeout(maintenanceHistoryTimerRef.current)
}
maintenanceHistoryTimerRef.current = window.setTimeout(() => {
setIsMaintenanceHistoryOpen(true)
maintenanceHistoryTimerRef.current = null
}, 220)
}, [])
const handleOpenMaintenanceHistoryFromCard = useCallback(() => {
setIsMaintenanceHistoryOpen(true)
}, [])
const handleMaintenanceUpdated = useCallback(async () => {
await refetch()
}, [refetch])
const handleRefetchPolicy = useCallback(async () => {
const result = await refetch()
return result.data?.getPhone?.policy
}, [refetch])
const handleLockStateChange = useCallback(async () => {
const result = await refetch()
return result.data?.getPhone?.policy?.locked
}, [refetch])
if (!numericDeviceId) {
return (
<section className="device-page">
<EmptyState
className="device-page__empty"
variant="not-found"
title="Некорректный ID устройства"
description="Проверьте ссылку или вернитесь к списку устройств."
>
<button
type="button"
onClick={() => navigate('/devices')}
>
Вернуться к списку
</button>
</EmptyState>
</section>
)
}
if (loading && !device) {
return <DevicePageSkeleton />
}
if (error || !device) {
return (
<section className="device-page">
<EmptyState
className="device-page__empty"
variant="not-found"
title="Устройство не найдено"
description="Возможно, устройство было удалено или ссылка устарела."
>
<button
type="button"
onClick={() => navigate('/devices')}
>
Вернуться к списку
</button>
</EmptyState>
</section>
)
}
return (
<section className={`device-page ${isAppsView ? 'device-page--apps' : ''}`}>
<AnimatePresence mode="wait" initial={false}>
{isAppsView ? (
<m.div
key="apps"
className="device-page__motion-view device-page__apps-motion"
variants={pageSwapVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<DeviceAppsView
deviceId={device.id}
deviceName={device.factoryNumber}
onBack={handleCloseApps}
/>
</m.div>
) : (
<m.div
key="overview"
className="device-page__motion-view"
variants={pageSwapVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<div className="device-page__grid">
<DeviceMainCard
device={device}
onOpenHistory={handleOpenHistory}
onOpenMaintenanceDetails={handleOpenMaintenanceDetails}
onManageMaintenance={handleManageMaintenance}
onOpenMaintenanceHistory={handleOpenMaintenanceHistoryFromCard}
/>
<DeviceMapCard device={device} />
<div className="device-page__details-row">
<DeviceStatsCards device={device} />
<DevicePermissionsCard
device={device}
onEditingChange={handlePermissionsEditingChange}
onRefetchPolicy={handleRefetchPolicy}
/>
<DeviceActionsCard
device={device}
onOpenApps={handleOpenApps}
onLockStateChange={handleLockStateChange}
/>
</div>
</div>
</m.div>
)}
</AnimatePresence>
{shouldMountHistoryModal && (
<Suspense fallback={null}>
<DeviceHistoryModal
open={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
device={device}
/>
</Suspense>
)}
{shouldMountMaintenanceModal && renderMaintenanceMode && (
<Suspense fallback={null}>
<DeviceMaintenanceModal
open={maintenanceMode !== null}
mode={renderMaintenanceMode}
device={device}
onOpenHistory={handleOpenMaintenanceHistory}
onOpenChange={(nextOpen) => {
if (!nextOpen) setMaintenanceMode(null)
}}
onUpdated={handleMaintenanceUpdated}
/>
</Suspense>
)}
{shouldMountMaintenanceHistoryModal && (
<Suspense fallback={null}>
<DeviceMaintenanceHistoryModal
open={isMaintenanceHistoryOpen}
device={device}
onOpenChange={setIsMaintenanceHistoryOpen}
/>
</Suspense>
)}
</section>
)
}