550 lines
16 KiB
TypeScript
550 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|