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

921 lines
25 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,
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 (
<tr
className="devices-table__row"
onClick={() => onOpen(device.id)}
>
<td className="devices-table__id">{device.id}</td>
<td>
<div className="device-info">
<div className="device-info__number">
{device.serial || '—'}
</div>
<div className="device-info__imei">
IMEI: {device.imei || '—'}
</div>
<div className="device-info__imei">
IMEI 2: {device.imei2 || '—'}
</div>
<div className="device-info__employee">
{device.org?.name || 'Не указана'}
</div>
</div>
</td>
<td>
<div
className={`devices-status ${getDeviceConditionClass(
device.techState?.needMaintenance,
)}`}
>
<span
className={`devices-dot ${getDeviceConditionDotClass(
device.techState?.needMaintenance,
)}`}
/>
{getDeviceConditionLabel(device.techState?.needMaintenance)}
</div>
</td>
<td>
<div
className={`devices-status ${getDeviceNetworkClass(
device.networkStatus,
)}`}
>
<span
className={`devices-dot ${getDeviceNetworkDotClass(
device.networkStatus,
)}`}
/>
{getDeviceNetworkLabel(device.networkStatus)}
</div>
</td>
<td>
<div className="device-icons">
<GpsIcon className={gpsEnabled ? 'is-active' : ''} />
<WifiIcon />
<BluetoothIcon className={bluetoothEnabled ? 'is-active' : ''} />
<Lock
className={policy?.locked ? 'is-danger' : ''}
size={16}
/>
<CameraIcon className={cameraEnabled ? 'is-active' : ''} />
<SimIcon className={simEnabled ? 'is-active' : ''} />
<VolumeIcon />
<Store />
</div>
</td>
<td>
<button
className="devices-map-btn"
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onOpenMap(device)
}}
>
<Map size={15} />
На карте
</button>
</td>
</tr>
)
})
type DevicesTableProps = {
devices: ApiDevice[]
onOpen: (deviceId: number) => void
onOpenMap: (device: ApiDevice) => void
}
const DevicesTable = memo(function DevicesTable({
devices,
onOpen,
onOpenMap,
}: DevicesTableProps) {
return (
<table className="devices-table">
<thead>
<tr>
<th>ID</th>
<th>Информация</th>
<th>Состояние</th>
<th>Связь</th>
<th>Статусы</th>
<th />
</tr>
</thead>
<tbody>
{devices.map((device) => (
<DevicesTableRow
key={device.id}
device={device}
onOpen={onOpen}
onOpenMap={onOpenMap}
/>
))}
</tbody>
</table>
)
})
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<PageDevice | null>(null)
const [lastMapDevice, setLastMapDevice] = useState<PageDevice | null>(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<GetPhonesVariables>(
() => ({
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<GetPhonesData, GetPhonesVariables>(
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<string, string | null>,
) => {
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<HTMLDivElement>,
) => {
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 (
<section className="devices-page">
<DevicesTabs />
<DevicesToolbar
isFiltersOpen={isFiltersOpen}
onToggleFilters={handleToggleFilters}
onAddDevice={handleAddDevice}
search={deviceSearch}
onSearchChange={handleDeviceSearchChange}
sortField={sortField}
sortDirection={sortDirection}
onSortChange={handleSortChange}
/>
<div
className={`devices-table-filter-container ${
!isFiltersDrawerViewport && isFiltersOpen
? 'devices-table-filter-container--filters-open'
: ''
}`}
>
<div className="devices-table-container">
<div className="employees-table-shadow">
<SimpleBar
className={`devices-table-card ${
isDevicesUpdating ? 'is-updating' : ''
}`}
>
<RevealContent
loading={isInitialLoading}
fallback={
<TableSkeleton
className="devices-table"
columns={devicesTableSkeletonColumns}
/>
}
>
{shouldShowErrorState && (
<div className="devices-state devices-state--error">
Не удалось загрузить устройства
</div>
)}
{shouldShowEmptyState && (
<EmptyState
className="devices-state"
variant={hasAppliedFilters ? 'not-found' : 'no-items'}
title={
hasAppliedFilters
? 'Не найдено результатов'
: 'Устройств пока нет'
}
description={
hasAppliedFilters
? 'По заданным параметрам не найдено ни одного устройства. Попробуйте изменить поиск или фильтры.'
: 'Добавьте первое устройство, чтобы оно появилось в списке.'
}
/>
)}
{shouldShowTable && (
<DevicesTable
devices={devices}
onOpen={handleOpenDevice}
onOpenMap={handleOpenMapDevice}
/>
)}
</RevealContent>
</SimpleBar>
</div>
{totalPages > 1 && (
<EmployeesPagination
currentPage={currentPage}
loading={loading}
items={paginationItems}
hasPrevPage={hasPrevPage}
hasNextPage={hasNextPage}
onPrev={handlePrevPage}
onNext={handleNextPage}
onJumpPrev={handleJumpPrevPage}
onJumpNext={handleJumpNextPage}
onPageChange={setDevicesPage}
/>
)}
</div>
<div
className={`devices-filters-slot ${
!isFiltersDrawerViewport && isFiltersOpen
? 'devices-filters-slot--open'
: ''
} ${
!isFiltersDrawerViewport &&
isFiltersOpen &&
!isFiltersTransitioning
? 'devices-filters-slot--visible'
: ''
}`}
onTransitionEnd={handleFiltersSlotTransitionEnd}
>
{shouldRenderInlineFilters && (
<Suspense fallback={null}>
<DevicesFiltersPanel isOpen={isFiltersOpen} />
</Suspense>
)}
</div>
</div>
<Dialog.Root
open={isFiltersDrawerViewport && isFiltersOpen}
onOpenChange={handleFiltersDrawerOpenChange}
>
<Dialog.Portal>
<Dialog.Overlay className="devices-filters-drawer__overlay" />
<Dialog.Content className="devices-filters-drawer">
<div className="devices-filters-drawer__header">
<Dialog.Title>Фильтры</Dialog.Title>
<Dialog.Close
className="devices-filters-drawer__close"
type="button"
aria-label="Закрыть фильтры"
>
<X size={18} />
</Dialog.Close>
</div>
<SimpleBar className="devices-filters-drawer__scroll">
<Suspense fallback={null}>
<DevicesFiltersPanel isOpen />
</Suspense>
</SimpleBar>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<AddDeviceModal
open={isAddDeviceOpen}
onOpenChange={setIsAddDeviceOpen}
/>
{shouldMountMapModal && (
<Suspense fallback={null}>
<DeviceMapModal
open={Boolean(mapDevice)}
device={mapDevice ?? lastMapDevice}
onOpenChange={handleMapModalOpenChange}
/>
</Suspense>
)}
</section>
)
}