aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/DeviceList.jsx66
-rw-r--r--src/main/DeviceRow.jsx145
-rw-r--r--src/main/EventsDrawer.jsx81
-rw-r--r--src/main/MainMap.jsx66
-rw-r--r--src/main/MainPage.jsx160
-rw-r--r--src/main/MainToolbar.jsx178
-rw-r--r--src/main/useFilter.js46
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]);
+};