921 lines
25 KiB
TypeScript
921 lines
25 KiB
TypeScript
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>
|
||
)
|
||
}
|