import {
lazy,
memo,
Suspense,
type TransitionEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useQuery } from '@apollo/client/react'
import * as Dialog from '@radix-ui/react-dialog'
import {
Map,
Lock,
Store,
X,
} from 'lucide-react'
import SimpleBar from 'simplebar-react'
import 'simplebar-react/dist/simplebar.min.css'
import { WifiIcon } from '../../assets/icons/Wifi'
import { BluetoothIcon } from '../../assets/icons/Bluetooth'
import { GpsIcon } from '../../assets/icons/Gps'
import { CameraIcon } from '../../assets/icons/Camera'
import { SimIcon } from '../../assets/icons/Sim'
import { VolumeIcon } from '../../assets/icons/Volume'
import './DevicesPage.scss'
import { DevicesTabs } from './components/DevicesTabs/DevicesTabs'
import {
DevicesToolbar,
devicesSortOptions,
type DevicesSortDirection,
type DevicesSortField,
} from './components/DevicesToolbar/DevicesToolbar'
import { GET_PHONES_QUERY } from '../../entities/device/api/device.graphql'
import type {
DeviceNetworkStatus,
GetPhonesData,
GetPhonesVariables,
} from '../../entities/device/model/types'
import type { Device as PageDevice } from '../DevicePage/types'
import type { Device as ApiDevice } from '../../entities/device/model/types'
import { EmployeesPagination } from '../EmployeesPage/components/EmployeesPagination/EmployeesPagination'
import { getPaginationItems } from '../EmployeesPage/lib/getPaginationItems'
import {
TableSkeleton,
type TableSkeletonColumn,
} from '../../shared/ui/TableSkeleton/TableSkeleton'
import { RevealContent } from '../../shared/ui/RevealContent/RevealContent'
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import {
preloadOnIdle,
useLazyMount,
} from '../../shared/lib/lazyMount'
import { AddDeviceModal } from './components/AddDeviceModal/AddDeviceModal'
const loadDeviceMapModal = () =>
import('./components/DeviceMapModal/DeviceMapModal').then((module) => ({
default: module.DeviceMapModal,
}))
const DeviceMapModal = lazy(loadDeviceMapModal)
const loadDevicesFiltersPanel = () =>
import('./components/DevicesFiltersPanel/DevicesFiltersPanel').then(
(module) => ({
default: module.DevicesFiltersPanel,
}),
)
const DevicesFiltersPanel = lazy(loadDevicesFiltersPanel)
const devicesTableSkeletonColumns: TableSkeletonColumn[] = [
{ width: '36px', headerWidth: '28px' },
{ variant: 'stack', lines: 4, width: '76%', headerWidth: '118px' },
{ variant: 'pill', headerWidth: '92px' },
{ variant: 'pill', headerWidth: '72px' },
{ variant: 'icons', headerWidth: '72px' },
{ variant: 'button', headerWidth: '42px' },
]
const DEVICE_NETWORK_STATUSES: DeviceNetworkStatus[] = [
'Online',
'Offline',
'Lost',
]
const FILTERS_DRAWER_MEDIA_QUERY = '(max-width: 1600px)'
function getIsFiltersDrawerViewport() {
if (typeof window === 'undefined') return false
return window.matchMedia(FILTERS_DRAWER_MEDIA_QUERY).matches
}
function isDeviceNetworkStatus(
value: string,
): value is DeviceNetworkStatus {
return DEVICE_NETWORK_STATUSES.includes(value as DeviceNetworkStatus)
}
function formatDateTime(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 getDeviceConditionLabel(needMaintenance?: boolean) {
return needMaintenance ? 'Требует ТО' : 'Исправно'
}
function getDeviceConditionClass(needMaintenance?: boolean) {
return needMaintenance ? 'devices-status--red' : 'devices-status--green'
}
function getDeviceConditionDotClass(needMaintenance?: boolean) {
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green'
}
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'В сети'
if (status === 'Lost') return 'Потерян'
return 'Не в сети'
}
function getDeviceNetworkClass(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'devices-status--green'
if (status === 'Lost') return 'devices-status--red'
return 'devices-status--gray'
}
function getDeviceNetworkDotClass(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'devices-dot--green'
if (status === 'Lost') return 'devices-dot--red'
return 'devices-dot--gray'
}
function getDeviceNetworkConnection(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'online'
if (status === 'Lost') return 'offlineDanger'
return 'offline'
}
type DevicesTableRowProps = {
device: ApiDevice
onOpen: (deviceId: number) => void
onOpenMap: (device: ApiDevice) => void
}
const DevicesTableRow = memo(function DevicesTableRow({
device,
onOpen,
onOpenMap,
}: DevicesTableRowProps) {
const policy = device.policy
const gpsEnabled = isPhonePolicyOptionEnabled(
policy?.GPS ?? policy?.canUseGPS,
)
const bluetoothEnabled = isPhonePolicyOptionEnabled(
policy?.bluetooth ?? policy?.canUseBluetooth,
)
const cameraEnabled = isPhonePolicyOptionEnabled(
policy?.camera ?? policy?.canUseCamera,
)
const simEnabled = isPhonePolicyOptionEnabled(
policy?.sim ?? policy?.canUseSim,
)
return (
onOpen(device.id)}
>
| {device.id} |
{device.serial || '—'}
IMEI: {device.imei || '—'}
IMEI 2: {device.imei2 || '—'}
{device.org?.name || 'Не указана'}
|
{getDeviceConditionLabel(device.techState?.needMaintenance)}
|
{getDeviceNetworkLabel(device.networkStatus)}
|
|
|
)
})
type DevicesTableProps = {
devices: ApiDevice[]
onOpen: (deviceId: number) => void
onOpenMap: (device: ApiDevice) => void
}
const DevicesTable = memo(function DevicesTable({
devices,
onOpen,
onOpenMap,
}: DevicesTableProps) {
return (
| ID |
Информация |
Состояние |
Связь |
Статусы |
|
{devices.map((device) => (
))}
)
})
export function DevicesPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [isFiltersDrawerViewport, setIsFiltersDrawerViewport] = useState(
getIsFiltersDrawerViewport,
)
const [isFiltersOpen, setIsFiltersOpen] = useState(
() => !getIsFiltersDrawerViewport(),
)
const [isFiltersTransitioning, setIsFiltersTransitioning] =
useState(false)
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
const [mapDevice, setMapDevice] = useState(null)
const [lastMapDevice, setLastMapDevice] = useState(null)
const shouldMountMapModal = useLazyMount(Boolean(mapDevice))
useEffect(() => {
return preloadOnIdle([
loadDeviceMapModal,
loadDevicesFiltersPanel,
])
}, [])
useEffect(() => {
const mediaQuery = window.matchMedia(FILTERS_DRAWER_MEDIA_QUERY)
function handleViewportChange(event: MediaQueryListEvent | MediaQueryList) {
setIsFiltersDrawerViewport(event.matches)
if (event.matches) {
setIsFiltersOpen(false)
}
}
handleViewportChange(mediaQuery)
mediaQuery.addEventListener('change', handleViewportChange)
return () => {
mediaQuery.removeEventListener('change', handleViewportChange)
}
}, [])
const currentPage = Number(searchParams.get('dPage') ?? 0)
const deviceSearchParam = searchParams.get('dQuery') ?? ''
const [deviceSearch, setDeviceSearch] = useState(deviceSearchParam)
const [debouncedDeviceSearch, setDebouncedDeviceSearch] = useState(
deviceSearchParam.trim(),
)
const selectedOrganisationParam = searchParams.get('dOrgs') ?? ''
const selectedOrganisationIds = useMemo(
() =>
selectedOrganisationParam
? selectedOrganisationParam.split(',').filter(Boolean)
: [],
[selectedOrganisationParam],
)
const networkStatusParam = searchParams.get('dNetwork')
const selectedNetworkStatuses = useMemo(
() =>
networkStatusParam === 'none'
? []
: networkStatusParam
?.split(',')
.filter(isDeviceNetworkStatus) ?? [],
[networkStatusParam],
)
const needMaintenanceParam = searchParams.get('dNeedMaintenance')
const needMaintenance =
needMaintenanceParam === 'true'
? true
: needMaintenanceParam === 'false'
? false
: undefined
const lockedParam = searchParams.get('dLocked')
const locked =
lockedParam === 'true'
? true
: lockedParam === 'false'
? false
: undefined
const sortField =
(searchParams.get('dSortField') as
| DevicesSortField
| null) ?? devicesSortOptions[0].sortField
const sortDirection =
(searchParams.get('dSortDirection') as
| DevicesSortDirection
| null) ?? devicesSortOptions[0].sortDirection
useEffect(() => {
const timeoutId = window.setTimeout(() => {
const nextQuery = deviceSearch.trim()
setDebouncedDeviceSearch(nextQuery)
setSearchParams(
(prev) => {
const currentQuery = prev.get('dQuery') ?? ''
if (currentQuery === nextQuery) return prev
const next = new URLSearchParams(prev)
if (nextQuery) {
next.set('dQuery', nextQuery)
} else {
next.delete('dQuery')
}
next.delete('dPage')
return next
},
{
replace: true,
},
)
}, 350)
return () => {
window.clearTimeout(timeoutId)
}
}, [deviceSearch, setSearchParams])
useEffect(() => {
if (!isFiltersTransitioning) return
const timeoutId = window.setTimeout(() => {
setIsFiltersTransitioning(false)
}, 430)
return () => {
window.clearTimeout(timeoutId)
}
}, [isFiltersOpen, isFiltersTransitioning])
const phonesVariables = useMemo(
() => ({
page: currentPage,
query: debouncedDeviceSearch || undefined,
sortDirection,
sortField,
orgs:
selectedOrganisationIds.length > 0
? selectedOrganisationIds
: undefined,
locked,
networkStatus:
networkStatusParam
? selectedNetworkStatuses
: undefined,
needMaintenance,
}),
[
currentPage,
debouncedDeviceSearch,
locked,
needMaintenance,
networkStatusParam,
selectedNetworkStatuses,
selectedOrganisationIds,
sortDirection,
sortField,
],
)
const {
data,
previousData,
loading,
error,
} = useQuery(
GET_PHONES_QUERY,
{
variables: phonesVariables,
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
notifyOnNetworkStatusChange: true,
//pollInterval: 15000
},
)
const response =
data?.getPhones ?? previousData?.getPhones
const devices = response?.page ?? []
const totalPages = response?.totalPages ?? 0
const isInitialLoading = loading && !response
const isDevicesUpdating = loading && Boolean(response)
const hasDevices = devices.length > 0
const hasAppliedFilters = Boolean(
debouncedDeviceSearch ||
selectedOrganisationIds.length > 0 ||
networkStatusParam ||
needMaintenance !== undefined ||
locked !== undefined,
)
const shouldShowEmptyState = !loading && !error && !hasDevices
const shouldShowErrorState = Boolean(error) && !hasDevices
const shouldShowTable = hasDevices && !shouldShowErrorState
const shouldRenderInlineFilters =
!isFiltersDrawerViewport && (isFiltersOpen || isFiltersTransitioning)
const hasPrevPage = currentPage > 0
const hasNextPage =
totalPages > 0 && currentPage < totalPages - 1
const paginationItems = useMemo(
() => getPaginationItems(currentPage, totalPages),
[currentPage, totalPages],
)
const updateSearchParams = useCallback((
updates: Record,
) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === '') {
next.delete(key)
return
}
next.set(key, value)
})
return next
},
{
replace: true,
},
)
}, [setSearchParams])
const setDevicesPage = useCallback((page: number) => {
updateSearchParams({
dPage: page > 0 ? String(page) : null,
})
}, [updateSearchParams])
const handleNextPage = useCallback(() => {
if (!hasNextPage || loading) return
setDevicesPage(currentPage + 1)
}, [currentPage, hasNextPage, loading, setDevicesPage])
const handlePrevPage = useCallback(() => {
if (!hasPrevPage || loading) return
setDevicesPage(Math.max(0, currentPage - 1))
}, [currentPage, hasPrevPage, loading, setDevicesPage])
const handleJumpPrevPage = useCallback(() => {
if (loading) return
setDevicesPage(Math.max(0, currentPage - 5))
}, [currentPage, loading, setDevicesPage])
const handleJumpNextPage = useCallback(() => {
if (loading) return
setDevicesPage(
Math.min(totalPages - 1, currentPage + 5),
)
}, [currentPage, loading, setDevicesPage, totalPages])
const mapTableDeviceToPageDevice = useCallback((
device: ApiDevice,
): PageDevice => {
const policy = device.policy
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 needMaintenance =
device.techState?.needMaintenance ?? false
return {
id: device.id,
model: 'АРМАФОН S3.3+',
factoryNumber:
device.serial || 'Заводской номер не указан',
imei: device.imei || 'IMEI не указан',
imei2: device.imei2 || 'IMEI 2 не указан',
serialNumber: device.serial || undefined,
workTime: null,
employee: device.org?.name ?? null,
organisation: device.org?.name,
organisationId: device.org?.id ?? device.orgId,
policy,
organisationPolicy: device.org?.policy ?? null,
condition: needMaintenance
? 'inspection'
: 'ok',
connection: getDeviceNetworkConnection(device.networkStatus),
connectionText: getDeviceNetworkLabel(device.networkStatus),
registeredAt: undefined,
lastLocationDate: device.lastLocation?.date,
lastLocationAt: device.lastLocation?.date
? formatDateTime(device.lastLocation.date)
: undefined,
location: device.lastLocation
? {
lat: device.lastLocation.lat,
lng: device.lastLocation.lng,
}
: undefined,
route: [],
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: undefined,
batteryMaxCapacity: undefined,
chargeCycles: undefined,
totalWorkTime: undefined,
mediumImpacts: undefined,
}
}, [])
const handleToggleFilters = useCallback(() => {
setIsFiltersTransitioning(true)
setIsFiltersOpen((prev) => !prev)
}, [])
const handleAddDevice = useCallback(() => {
setIsAddDeviceOpen(true)
}, [])
const handleDeviceSearchChange = useCallback((value: string) => {
setDeviceSearch(value)
}, [])
const handleSortChange = useCallback((option: typeof devicesSortOptions[number]) => {
updateSearchParams({
dSortField:
option.sortField === devicesSortOptions[0].sortField
? null
: option.sortField,
dSortDirection:
option.sortDirection === devicesSortOptions[0].sortDirection
? null
: option.sortDirection,
dPage: null,
})
}, [updateSearchParams])
const handleOpenDevice = useCallback((deviceId: number) => {
navigate(`/devices/${deviceId}`)
}, [navigate])
const handleOpenMapDevice = useCallback((device: ApiDevice) => {
const pageDevice = mapTableDeviceToPageDevice(device)
setLastMapDevice(pageDevice)
setMapDevice(pageDevice)
}, [mapTableDeviceToPageDevice])
const handleFiltersSlotTransitionEnd = useCallback((
event: TransitionEvent,
) => {
if (
event.currentTarget !== event.target ||
event.propertyName !== 'flex-basis'
) {
return
}
setIsFiltersTransitioning(false)
}, [])
const handleFiltersDrawerOpenChange = useCallback((open: boolean) => {
setIsFiltersTransitioning(false)
setIsFiltersOpen(open)
}, [])
const handleMapModalOpenChange = useCallback((open: boolean) => {
if (!open) {
setMapDevice(null)
}
}, [])
return (
}
>
{shouldShowErrorState && (
Не удалось загрузить устройства
)}
{shouldShowEmptyState && (
)}
{shouldShowTable && (
)}
{totalPages > 1 && (
)}
{shouldRenderInlineFilters && (
)}
Фильтры
{shouldMountMapModal && (
)}
)
}