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 ( {devices.map((device) => ( ))}
ID Информация Состояние Связь Статусы
) }) 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 && ( )}
) }