diff options
Diffstat (limited to 'src/main')
-rw-r--r-- | src/main/DeviceList.jsx | 66 | ||||
-rw-r--r-- | src/main/DeviceRow.jsx | 145 | ||||
-rw-r--r-- | src/main/EventsDrawer.jsx | 81 | ||||
-rw-r--r-- | src/main/MainMap.jsx | 66 | ||||
-rw-r--r-- | src/main/MainPage.jsx | 160 | ||||
-rw-r--r-- | src/main/MainToolbar.jsx | 178 | ||||
-rw-r--r-- | src/main/useFilter.js | 46 |
7 files changed, 742 insertions, 0 deletions
diff --git a/src/main/DeviceList.jsx b/src/main/DeviceList.jsx new file mode 100644 index 00000000..eb51232f --- /dev/null +++ b/src/main/DeviceList.jsx @@ -0,0 +1,66 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import makeStyles from '@mui/styles/makeStyles'; +import { FixedSizeList } from 'react-window'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { devicesActions } from '../store'; +import { useEffectAsync } from '../reactHelper'; +import DeviceRow from './DeviceRow'; + +const useStyles = makeStyles((theme) => ({ + list: { + maxHeight: '100%', + }, + listInner: { + position: 'relative', + margin: theme.spacing(1.5, 0), + }, +})); + +const DeviceList = ({ devices }) => { + const classes = useStyles(); + const dispatch = useDispatch(); + const listInnerEl = useRef(null); + + if (listInnerEl.current) { + listInnerEl.current.className = classes.listInner; + } + + const [, setTime] = useState(Date.now()); + + useEffect(() => { + const interval = setInterval(() => setTime(Date.now()), 60000); + return () => { + clearInterval(interval); + }; + }, []); + + useEffectAsync(async () => { + const response = await fetch('/api/devices'); + if (response.ok) { + dispatch(devicesActions.refresh(await response.json())); + } else { + throw Error(await response.text()); + } + }, []); + + return ( + <AutoSizer className={classes.list}> + {({ height, width }) => ( + <FixedSizeList + width={width} + height={height} + itemCount={devices.length} + itemData={devices} + itemSize={72} + overscanCount={10} + innerRef={listInnerEl} + > + {DeviceRow} + </FixedSizeList> + )} + </AutoSizer> + ); +}; + +export default DeviceList; diff --git a/src/main/DeviceRow.jsx b/src/main/DeviceRow.jsx new file mode 100644 index 00000000..d9c1a189 --- /dev/null +++ b/src/main/DeviceRow.jsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import makeStyles from '@mui/styles/makeStyles'; +import { + IconButton, Tooltip, Avatar, ListItemAvatar, ListItemText, ListItemButton, +} from '@mui/material'; +import BatteryFullIcon from '@mui/icons-material/BatteryFull'; +import BatteryChargingFullIcon from '@mui/icons-material/BatteryChargingFull'; +import Battery60Icon from '@mui/icons-material/Battery60'; +import BatteryCharging60Icon from '@mui/icons-material/BatteryCharging60'; +import Battery20Icon from '@mui/icons-material/Battery20'; +import BatteryCharging20Icon from '@mui/icons-material/BatteryCharging20'; +import ErrorIcon from '@mui/icons-material/Error'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { devicesActions } from '../store'; +import { + formatAlarm, formatBoolean, formatPercentage, formatStatus, getStatusColor, +} from '../common/util/formatter'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import { mapIconKey, mapIcons } from '../map/core/preloadImages'; +import { useAdministrator } from '../common/util/permissions'; +import EngineIcon from '../resources/images/data/engine.svg?react'; +import { useAttributePreference } from '../common/util/preferences'; + +dayjs.extend(relativeTime); + +const useStyles = makeStyles((theme) => ({ + icon: { + width: '25px', + height: '25px', + filter: 'brightness(0) invert(1)', + }, + batteryText: { + fontSize: '0.75rem', + fontWeight: 'normal', + lineHeight: '0.875rem', + }, + success: { + color: theme.palette.success.main, + }, + warning: { + color: theme.palette.warning.main, + }, + error: { + color: theme.palette.error.main, + }, + neutral: { + color: theme.palette.neutral.main, + }, +})); + +const DeviceRow = ({ data, index, style }) => { + const classes = useStyles(); + const dispatch = useDispatch(); + const t = useTranslation(); + + const admin = useAdministrator(); + + const item = data[index]; + const position = useSelector((state) => state.session.positions[item.id]); + + const devicePrimary = useAttributePreference('devicePrimary', 'name'); + const deviceSecondary = useAttributePreference('deviceSecondary', ''); + + const secondaryText = () => { + let status; + if (item.status === 'online' || !item.lastUpdate) { + status = formatStatus(item.status, t); + } else { + status = dayjs(item.lastUpdate).fromNow(); + } + return ( + <> + {deviceSecondary && item[deviceSecondary] && `${item[deviceSecondary]} • `} + <span className={classes[getStatusColor(item.status)]}>{status}</span> + </> + ); + }; + + return ( + <div style={style}> + <ListItemButton + key={item.id} + onClick={() => dispatch(devicesActions.selectId(item.id))} + disabled={!admin && item.disabled} + > + <ListItemAvatar> + <Avatar> + <img className={classes.icon} src={mapIcons[mapIconKey(item.category)]} alt="" /> + </Avatar> + </ListItemAvatar> + <ListItemText + primary={item[devicePrimary]} + primaryTypographyProps={{ noWrap: true }} + secondary={secondaryText()} + secondaryTypographyProps={{ noWrap: true }} + /> + {position && ( + <> + {position.attributes.hasOwnProperty('alarm') && ( + <Tooltip title={`${t('eventAlarm')}: ${formatAlarm(position.attributes.alarm, t)}`}> + <IconButton size="small"> + <ErrorIcon fontSize="small" className={classes.error} /> + </IconButton> + </Tooltip> + )} + {position.attributes.hasOwnProperty('ignition') && ( + <Tooltip title={`${t('positionIgnition')}: ${formatBoolean(position.attributes.ignition, t)}`}> + <IconButton size="small"> + {position.attributes.ignition ? ( + <EngineIcon width={20} height={20} className={classes.success} /> + ) : ( + <EngineIcon width={20} height={20} className={classes.neutral} /> + )} + </IconButton> + </Tooltip> + )} + {position.attributes.hasOwnProperty('batteryLevel') && ( + <Tooltip title={`${t('positionBatteryLevel')}: ${formatPercentage(position.attributes.batteryLevel)}`}> + <IconButton size="small"> + {(position.attributes.batteryLevel > 70 && ( + position.attributes.charge + ? (<BatteryChargingFullIcon fontSize="small" className={classes.success} />) + : (<BatteryFullIcon fontSize="small" className={classes.success} />) + )) || (position.attributes.batteryLevel > 30 && ( + position.attributes.charge + ? (<BatteryCharging60Icon fontSize="small" className={classes.warning} />) + : (<Battery60Icon fontSize="small" className={classes.warning} />) + )) || ( + position.attributes.charge + ? (<BatteryCharging20Icon fontSize="small" className={classes.error} />) + : (<Battery20Icon fontSize="small" className={classes.error} />) + )} + </IconButton> + </Tooltip> + )} + </> + )} + </ListItemButton> + </div> + ); +}; + +export default DeviceRow; diff --git a/src/main/EventsDrawer.jsx b/src/main/EventsDrawer.jsx new file mode 100644 index 00000000..f9602e95 --- /dev/null +++ b/src/main/EventsDrawer.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + Drawer, IconButton, List, ListItemButton, ListItemText, Toolbar, Typography, +} from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { formatNotificationTitle, formatTime } from '../common/util/formatter'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import { eventsActions } from '../store'; +import { usePreference } from '../common/util/preferences'; + +const useStyles = makeStyles((theme) => ({ + drawer: { + width: theme.dimensions.eventsDrawerWidth, + }, + toolbar: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, + title: { + flexGrow: 1, + }, +})); + +const EventsDrawer = ({ open, onClose }) => { + const classes = useStyles(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const t = useTranslation(); + + const hours12 = usePreference('twelveHourFormat'); + + const devices = useSelector((state) => state.devices.items); + + const events = useSelector((state) => state.events.items); + + const formatType = (event) => formatNotificationTitle(t, { + type: event.type, + attributes: { + alarms: event.attributes.alarm, + }, + }); + + return ( + <Drawer + anchor="right" + open={open} + onClose={onClose} + > + <Toolbar className={classes.toolbar} disableGutters> + <Typography variant="h6" className={classes.title}> + {t('reportEvents')} + </Typography> + <IconButton size="small" color="inherit" onClick={() => dispatch(eventsActions.deleteAll())}> + <DeleteIcon fontSize="small" /> + </IconButton> + </Toolbar> + <List className={classes.drawer} dense> + {events.map((event) => ( + <ListItemButton + key={event.id} + onClick={() => navigate(`/event/${event.id}`)} + disabled={!event.id} + > + <ListItemText + primary={`${devices[event.deviceId]?.name} • ${formatType(event)}`} + secondary={formatTime(event.eventTime, 'seconds', hours12)} + /> + <IconButton size="small" onClick={() => dispatch(eventsActions.delete(event))}> + <DeleteIcon fontSize="small" className={classes.delete} /> + </IconButton> + </ListItemButton> + ))} + </List> + </Drawer> + ); +}; + +export default EventsDrawer; diff --git a/src/main/MainMap.jsx b/src/main/MainMap.jsx new file mode 100644 index 00000000..3b57c745 --- /dev/null +++ b/src/main/MainMap.jsx @@ -0,0 +1,66 @@ +import React, { useCallback } from 'react'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useDispatch, useSelector } from 'react-redux'; +import MapView from '../map/core/MapView'; +import MapSelectedDevice from '../map/main/MapSelectedDevice'; +import MapAccuracy from '../map/main/MapAccuracy'; +import MapGeofence from '../map/MapGeofence'; +import MapCurrentLocation from '../map/MapCurrentLocation'; +import PoiMap from '../map/main/PoiMap'; +import MapPadding from '../map/MapPadding'; +import { devicesActions } from '../store'; +import MapDefaultCamera from '../map/main/MapDefaultCamera'; +import MapLiveRoutes from '../map/main/MapLiveRoutes'; +import MapPositions from '../map/MapPositions'; +import MapOverlay from '../map/overlay/MapOverlay'; +import MapGeocoder from '../map/geocoder/MapGeocoder'; +import MapScale from '../map/MapScale'; +import MapNotification from '../map/notification/MapNotification'; +import useFeatures from '../common/util/useFeatures'; + +const MainMap = ({ filteredPositions, selectedPosition, onEventsClick }) => { + const theme = useTheme(); + const dispatch = useDispatch(); + + const desktop = useMediaQuery(theme.breakpoints.up('md')); + + const eventsAvailable = useSelector((state) => !!state.events.items.length); + + const features = useFeatures(); + + const onMarkerClick = useCallback((_, deviceId) => { + dispatch(devicesActions.selectId(deviceId)); + }, [dispatch]); + + return ( + <> + <MapView> + <MapOverlay /> + <MapGeofence /> + <MapAccuracy positions={filteredPositions} /> + <MapLiveRoutes /> + <MapPositions + positions={filteredPositions} + onClick={onMarkerClick} + selectedPosition={selectedPosition} + showStatus + /> + <MapDefaultCamera /> + <MapSelectedDevice /> + <PoiMap /> + </MapView> + <MapScale /> + <MapCurrentLocation /> + <MapGeocoder /> + {!features.disableEvents && ( + <MapNotification enabled={eventsAvailable} onClick={onEventsClick} /> + )} + {desktop && ( + <MapPadding left={parseInt(theme.dimensions.drawerWidthDesktop, 10)} /> + )} + </> + ); +}; + +export default MainMap; diff --git a/src/main/MainPage.jsx b/src/main/MainPage.jsx new file mode 100644 index 00000000..8369ba97 --- /dev/null +++ b/src/main/MainPage.jsx @@ -0,0 +1,160 @@ +import React, { + useState, useCallback, useEffect, +} from 'react'; +import { Paper } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useDispatch, useSelector } from 'react-redux'; +import DeviceList from './DeviceList'; +import BottomMenu from '../common/components/BottomMenu'; +import StatusCard from '../common/components/StatusCard'; +import { devicesActions } from '../store'; +import usePersistedState from '../common/util/usePersistedState'; +import EventsDrawer from './EventsDrawer'; +import useFilter from './useFilter'; +import MainToolbar from './MainToolbar'; +import MainMap from './MainMap'; +import { useAttributePreference } from '../common/util/preferences'; + +const useStyles = makeStyles((theme) => ({ + root: { + height: '100%', + }, + sidebar: { + pointerEvents: 'none', + display: 'flex', + flexDirection: 'column', + [theme.breakpoints.up('md')]: { + position: 'fixed', + left: 0, + top: 0, + height: `calc(100% - ${theme.spacing(3)})`, + width: theme.dimensions.drawerWidthDesktop, + margin: theme.spacing(1.5), + zIndex: 3, + }, + [theme.breakpoints.down('md')]: { + height: '100%', + width: '100%', + }, + }, + header: { + pointerEvents: 'auto', + zIndex: 6, + }, + footer: { + pointerEvents: 'auto', + zIndex: 5, + }, + middle: { + flex: 1, + display: 'grid', + }, + contentMap: { + pointerEvents: 'auto', + gridArea: '1 / 1', + }, + contentList: { + pointerEvents: 'auto', + gridArea: '1 / 1', + zIndex: 4, + }, +})); + +const MainPage = () => { + const classes = useStyles(); + const dispatch = useDispatch(); + const theme = useTheme(); + + const desktop = useMediaQuery(theme.breakpoints.up('md')); + + const mapOnSelect = useAttributePreference('mapOnSelect', true); + + const selectedDeviceId = useSelector((state) => state.devices.selectedId); + const positions = useSelector((state) => state.session.positions); + const [filteredPositions, setFilteredPositions] = useState([]); + const selectedPosition = filteredPositions.find((position) => selectedDeviceId && position.deviceId === selectedDeviceId); + + const [filteredDevices, setFilteredDevices] = useState([]); + + const [keyword, setKeyword] = useState(''); + const [filter, setFilter] = usePersistedState('filter', { + statuses: [], + groups: [], + }); + const [filterSort, setFilterSort] = usePersistedState('filterSort', ''); + const [filterMap, setFilterMap] = usePersistedState('filterMap', false); + + const [devicesOpen, setDevicesOpen] = useState(desktop); + const [eventsOpen, setEventsOpen] = useState(false); + + const onEventsClick = useCallback(() => setEventsOpen(true), [setEventsOpen]); + + useEffect(() => { + if (!desktop && mapOnSelect && selectedDeviceId) { + setDevicesOpen(false); + } + }, [desktop, mapOnSelect, selectedDeviceId]); + + useFilter(keyword, filter, filterSort, filterMap, positions, setFilteredDevices, setFilteredPositions); + + return ( + <div className={classes.root}> + {desktop && ( + <MainMap + filteredPositions={filteredPositions} + selectedPosition={selectedPosition} + onEventsClick={onEventsClick} + /> + )} + <div className={classes.sidebar}> + <Paper square elevation={3} className={classes.header}> + <MainToolbar + filteredDevices={filteredDevices} + devicesOpen={devicesOpen} + setDevicesOpen={setDevicesOpen} + keyword={keyword} + setKeyword={setKeyword} + filter={filter} + setFilter={setFilter} + filterSort={filterSort} + setFilterSort={setFilterSort} + filterMap={filterMap} + setFilterMap={setFilterMap} + /> + </Paper> + <div className={classes.middle}> + {!desktop && ( + <div className={classes.contentMap}> + <MainMap + filteredPositions={filteredPositions} + selectedPosition={selectedPosition} + onEventsClick={onEventsClick} + /> + </div> + )} + <Paper square className={classes.contentList} style={devicesOpen ? {} : { visibility: 'hidden' }}> + <DeviceList devices={filteredDevices} /> + </Paper> + </div> + {desktop && ( + <div className={classes.footer}> + <BottomMenu /> + </div> + )} + </div> + <EventsDrawer open={eventsOpen} onClose={() => setEventsOpen(false)} /> + {selectedDeviceId && ( + <StatusCard + deviceId={selectedDeviceId} + position={selectedPosition} + onClose={() => dispatch(devicesActions.selectId(null))} + desktopPadding={theme.dimensions.drawerWidthDesktop} + /> + )} + </div> + ); +}; + +export default MainPage; diff --git a/src/main/MainToolbar.jsx b/src/main/MainToolbar.jsx new file mode 100644 index 00000000..b029529c --- /dev/null +++ b/src/main/MainToolbar.jsx @@ -0,0 +1,178 @@ +import React, { useState, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + Toolbar, IconButton, OutlinedInput, InputAdornment, Popover, FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel, Checkbox, Badge, ListItemButton, ListItemText, Tooltip, +} from '@mui/material'; +import { makeStyles, useTheme } from '@mui/styles'; +import MapIcon from '@mui/icons-material/Map'; +import ViewListIcon from '@mui/icons-material/ViewList'; +import AddIcon from '@mui/icons-material/Add'; +import TuneIcon from '@mui/icons-material/Tune'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import { useDeviceReadonly } from '../common/util/permissions'; +import DeviceRow from './DeviceRow'; + +const useStyles = makeStyles((theme) => ({ + toolbar: { + display: 'flex', + gap: theme.spacing(1), + }, + filterPanel: { + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(2), + gap: theme.spacing(2), + width: theme.dimensions.drawerWidthTablet, + }, +})); + +const MainToolbar = ({ + filteredDevices, + devicesOpen, + setDevicesOpen, + keyword, + setKeyword, + filter, + setFilter, + filterSort, + setFilterSort, + filterMap, + setFilterMap, +}) => { + const classes = useStyles(); + const theme = useTheme(); + const navigate = useNavigate(); + const t = useTranslation(); + + const deviceReadonly = useDeviceReadonly(); + + const groups = useSelector((state) => state.groups.items); + const devices = useSelector((state) => state.devices.items); + + const toolbarRef = useRef(); + const inputRef = useRef(); + const [filterAnchorEl, setFilterAnchorEl] = useState(null); + const [devicesAnchorEl, setDevicesAnchorEl] = useState(null); + + const deviceStatusCount = (status) => Object.values(devices).filter((d) => d.status === status).length; + + return ( + <Toolbar ref={toolbarRef} className={classes.toolbar}> + <IconButton edge="start" onClick={() => setDevicesOpen(!devicesOpen)}> + {devicesOpen ? <MapIcon /> : <ViewListIcon />} + </IconButton> + <OutlinedInput + ref={inputRef} + placeholder={t('sharedSearchDevices')} + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onFocus={() => setDevicesAnchorEl(toolbarRef.current)} + onBlur={() => setDevicesAnchorEl(null)} + endAdornment={( + <InputAdornment position="end"> + <IconButton size="small" edge="end" onClick={() => setFilterAnchorEl(inputRef.current)}> + <Badge color="info" variant="dot" invisible={!filter.statuses.length && !filter.groups.length}> + <TuneIcon fontSize="small" /> + </Badge> + </IconButton> + </InputAdornment> + )} + size="small" + fullWidth + /> + <Popover + open={!!devicesAnchorEl && !devicesOpen} + anchorEl={devicesAnchorEl} + onClose={() => setDevicesAnchorEl(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: Number(theme.spacing(2).slice(0, -2)), + }} + marginThreshold={0} + PaperProps={{ + style: { width: `calc(${toolbarRef.current?.clientWidth}px - ${theme.spacing(4)})` }, + }} + elevation={1} + disableAutoFocus + disableEnforceFocus + > + {filteredDevices.slice(0, 3).map((_, index) => ( + <DeviceRow key={filteredDevices[index].id} data={filteredDevices} index={index} /> + ))} + {filteredDevices.length > 3 && ( + <ListItemButton alignItems="center" onClick={() => setDevicesOpen(true)}> + <ListItemText + primary={t('notificationAlways')} + style={{ textAlign: 'center' }} + /> + </ListItemButton> + )} + </Popover> + <Popover + open={!!filterAnchorEl} + anchorEl={filterAnchorEl} + onClose={() => setFilterAnchorEl(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + > + <div className={classes.filterPanel}> + <FormControl> + <InputLabel>{t('deviceStatus')}</InputLabel> + <Select + label={t('deviceStatus')} + value={filter.statuses} + onChange={(e) => setFilter({ ...filter, statuses: e.target.value })} + multiple + > + <MenuItem value="online">{`${t('deviceStatusOnline')} (${deviceStatusCount('online')})`}</MenuItem> + <MenuItem value="offline">{`${t('deviceStatusOffline')} (${deviceStatusCount('offline')})`}</MenuItem> + <MenuItem value="unknown">{`${t('deviceStatusUnknown')} (${deviceStatusCount('unknown')})`}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsGroups')}</InputLabel> + <Select + label={t('settingsGroups')} + value={filter.groups} + onChange={(e) => setFilter({ ...filter, groups: e.target.value })} + multiple + > + {Object.values(groups).sort((a, b) => a.name.localeCompare(b.name)).map((group) => ( + <MenuItem key={group.id} value={group.id}>{group.name}</MenuItem> + ))} + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('sharedSortBy')}</InputLabel> + <Select + label={t('sharedSortBy')} + value={filterSort} + onChange={(e) => setFilterSort(e.target.value)} + displayEmpty + > + <MenuItem value="">{'\u00a0'}</MenuItem> + <MenuItem value="name">{t('sharedName')}</MenuItem> + <MenuItem value="lastUpdate">{t('deviceLastUpdate')}</MenuItem> + </Select> + </FormControl> + <FormGroup> + <FormControlLabel + control={<Checkbox checked={filterMap} onChange={(e) => setFilterMap(e.target.checked)} />} + label={t('sharedFilterMap')} + /> + </FormGroup> + </div> + </Popover> + <IconButton edge="end" onClick={() => navigate('/settings/device')} disabled={deviceReadonly}> + <Tooltip open={!deviceReadonly && Object.keys(devices).length === 0} title={t('deviceRegisterFirst')} arrow> + <AddIcon /> + </Tooltip> + </IconButton> + </Toolbar> + ); +}; + +export default MainToolbar; diff --git a/src/main/useFilter.js b/src/main/useFilter.js new file mode 100644 index 00000000..ccda6e14 --- /dev/null +++ b/src/main/useFilter.js @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import dayjs from 'dayjs'; + +export default (keyword, filter, filterSort, filterMap, positions, setFilteredDevices, setFilteredPositions) => { + const groups = useSelector((state) => state.groups.items); + const devices = useSelector((state) => state.devices.items); + + useEffect(() => { + const deviceGroups = (device) => { + const groupIds = []; + let { groupId } = device; + while (groupId) { + groupIds.push(groupId); + groupId = groups[groupId]?.groupId || 0; + } + return groupIds; + }; + + const filtered = Object.values(devices) + .filter((device) => !filter.statuses.length || filter.statuses.includes(device.status)) + .filter((device) => !filter.groups.length || deviceGroups(device).some((id) => filter.groups.includes(id))) + .filter((device) => { + const lowerCaseKeyword = keyword.toLowerCase(); + return [device.name, device.uniqueId, device.phone, device.model, device.contact].some((s) => s && s.toLowerCase().includes(lowerCaseKeyword)); + }); + switch (filterSort) { + case 'name': + filtered.sort((device1, device2) => device1.name.localeCompare(device2.name)); + break; + case 'lastUpdate': + filtered.sort((device1, device2) => { + const time1 = device1.lastUpdate ? dayjs(device1.lastUpdate).valueOf() : 0; + const time2 = device2.lastUpdate ? dayjs(device2.lastUpdate).valueOf() : 0; + return time2 - time1; + }); + break; + default: + break; + } + setFilteredDevices(filtered); + setFilteredPositions(filterMap + ? filtered.map((device) => positions[device.id]).filter(Boolean) + : Object.values(positions)); + }, [keyword, filter, filterSort, filterMap, groups, devices, positions, setFilteredDevices, setFilteredPositions]); +}; |