aboutsummaryrefslogtreecommitdiff
path: root/src/reports
diff options
context:
space:
mode:
Diffstat (limited to 'src/reports')
-rw-r--r--src/reports/ChartReportPage.jsx152
-rw-r--r--src/reports/CombinedReportPage.jsx105
-rw-r--r--src/reports/EventReportPage.jsx232
-rw-r--r--src/reports/LogsPage.jsx84
-rw-r--r--src/reports/RouteReportPage.jsx173
-rw-r--r--src/reports/ScheduledPage.jsx106
-rw-r--r--src/reports/StatisticsPage.jsx85
-rw-r--r--src/reports/StopReportPage.jsx172
-rw-r--r--src/reports/SummaryReportPage.jsx152
-rw-r--r--src/reports/TripReportPage.jsx216
-rw-r--r--src/reports/common/scheduleReport.js26
-rw-r--r--src/reports/common/useReportStyles.js49
-rw-r--r--src/reports/components/ColumnSelect.jsx34
-rw-r--r--src/reports/components/ReportFilter.jsx215
-rw-r--r--src/reports/components/ReportsMenu.jsx116
15 files changed, 1917 insertions, 0 deletions
diff --git a/src/reports/ChartReportPage.jsx b/src/reports/ChartReportPage.jsx
new file mode 100644
index 00000000..6175e1d8
--- /dev/null
+++ b/src/reports/ChartReportPage.jsx
@@ -0,0 +1,152 @@
+import dayjs from 'dayjs';
+import React, { useState } from 'react';
+import {
+ FormControl, InputLabel, Select, MenuItem,
+} from '@mui/material';
+import {
+ CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
+} from 'recharts';
+import ReportFilter from './components/ReportFilter';
+import { formatTime } from '../common/util/formatter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+import { useCatch } from '../reactHelper';
+import { useAttributePreference, usePreference } from '../common/util/preferences';
+import {
+ altitudeFromMeters, distanceFromMeters, speedFromKnots, volumeFromLiters,
+} from '../common/util/converter';
+import useReportStyles from './common/useReportStyles';
+
+const ChartReportPage = () => {
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const distanceUnit = useAttributePreference('distanceUnit');
+ const altitudeUnit = useAttributePreference('altitudeUnit');
+ const speedUnit = useAttributePreference('speedUnit');
+ const volumeUnit = useAttributePreference('volumeUnit');
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [items, setItems] = useState([]);
+ const [types, setTypes] = useState(['speed']);
+ const [type, setType] = useState('speed');
+
+ const values = items.map((it) => it[type]);
+ const minValue = Math.min(...values);
+ const maxValue = Math.max(...values);
+ const valueRange = maxValue - minValue;
+
+ const handleSubmit = useCatch(async ({ deviceId, from, to }) => {
+ const query = new URLSearchParams({ deviceId, from, to });
+ const response = await fetch(`/api/reports/route?${query.toString()}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (response.ok) {
+ const positions = await response.json();
+ const keySet = new Set();
+ const keyList = [];
+ const formattedPositions = positions.map((position) => {
+ const data = { ...position, ...position.attributes };
+ const formatted = {};
+ formatted.fixTime = dayjs(position.fixTime).valueOf();
+ Object.keys(data).filter((key) => !['id', 'deviceId'].includes(key)).forEach((key) => {
+ const value = data[key];
+ if (typeof value === 'number') {
+ keySet.add(key);
+ const definition = positionAttributes[key] || {};
+ switch (definition.dataType) {
+ case 'speed':
+ formatted[key] = speedFromKnots(value, speedUnit).toFixed(2);
+ break;
+ case 'altitude':
+ formatted[key] = altitudeFromMeters(value, altitudeUnit).toFixed(2);
+ break;
+ case 'distance':
+ formatted[key] = distanceFromMeters(value, distanceUnit).toFixed(2);
+ break;
+ case 'volume':
+ formatted[key] = volumeFromLiters(value, volumeUnit).toFixed(2);
+ break;
+ case 'hours':
+ formatted[key] = (value / 1000).toFixed(2);
+ break;
+ default:
+ formatted[key] = value;
+ break;
+ }
+ }
+ });
+ return formatted;
+ });
+ Object.keys(positionAttributes).forEach((key) => {
+ if (keySet.has(key)) {
+ keyList.push(key);
+ keySet.delete(key);
+ }
+ });
+ setTypes([...keyList, ...keySet]);
+ setItems(formattedPositions);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'reportChart']}>
+ <ReportFilter handleSubmit={handleSubmit} showOnly>
+ <div className={classes.filterItem}>
+ <FormControl fullWidth>
+ <InputLabel>{t('reportChartType')}</InputLabel>
+ <Select
+ label={t('reportChartType')}
+ value={type}
+ onChange={(e) => setType(e.target.value)}
+ disabled={!items.length}
+ >
+ {types.map((key) => (
+ <MenuItem key={key} value={key}>{positionAttributes[key]?.name || key}</MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ </div>
+ </ReportFilter>
+ {items.length > 0 && (
+ <div className={classes.chart}>
+ <ResponsiveContainer>
+ <LineChart
+ data={items}
+ margin={{
+ top: 10, right: 40, left: 0, bottom: 10,
+ }}
+ >
+ <XAxis
+ dataKey="fixTime"
+ type="number"
+ tickFormatter={(value) => formatTime(value, 'time', hours12)}
+ domain={['dataMin', 'dataMax']}
+ scale="time"
+ />
+ <YAxis
+ type="number"
+ tickFormatter={(value) => value.toFixed(2)}
+ domain={[minValue - valueRange / 5, maxValue + valueRange / 5]}
+ />
+ <CartesianGrid strokeDasharray="3 3" />
+ <Tooltip
+ formatter={(value, key) => [value, positionAttributes[key]?.name || key]}
+ labelFormatter={(value) => formatTime(value, 'seconds', hours12)}
+ />
+ <Line type="monotone" dataKey={type} />
+ </LineChart>
+ </ResponsiveContainer>
+ </div>
+ )}
+ </PageLayout>
+ );
+};
+
+export default ChartReportPage;
diff --git a/src/reports/CombinedReportPage.jsx b/src/reports/CombinedReportPage.jsx
new file mode 100644
index 00000000..a5000839
--- /dev/null
+++ b/src/reports/CombinedReportPage.jsx
@@ -0,0 +1,105 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import {
+ Table, TableBody, TableCell, TableHead, TableRow,
+} from '@mui/material';
+import ReportFilter from './components/ReportFilter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import { useCatch } from '../reactHelper';
+import MapView from '../map/core/MapView';
+import MapRoutePath from '../map/MapRoutePath';
+import useReportStyles from './common/useReportStyles';
+import TableShimmer from '../common/components/TableShimmer';
+import MapCamera from '../map/MapCamera';
+import MapGeofence from '../map/MapGeofence';
+import { formatTime } from '../common/util/formatter';
+import { usePreference } from '../common/util/preferences';
+import { prefixString } from '../common/util/stringUtils';
+import MapMarkers from '../map/MapMarkers';
+
+const CombinedReportPage = () => {
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const devices = useSelector((state) => state.devices.items);
+
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const createMarkers = () => items.flatMap((item) => item.events
+ .map((event) => item.positions.find((p) => event.positionId === p.id))
+ .filter((position) => position != null)
+ .map((position) => ({
+ latitude: position.latitude,
+ longitude: position.longitude,
+ })));
+
+ const handleSubmit = useCatch(async ({ deviceIds, groupIds, from, to }) => {
+ const query = new URLSearchParams({ from, to });
+ deviceIds.forEach((deviceId) => query.append('deviceId', deviceId));
+ groupIds.forEach((groupId) => query.append('groupId', groupId));
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/reports/combined?${query.toString()}`);
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'reportCombined']}>
+ <div className={classes.container}>
+ {Boolean(items.length) && (
+ <div className={classes.containerMap}>
+ <MapView>
+ <MapGeofence />
+ {items.map((item) => (
+ <MapRoutePath
+ key={item.deviceId}
+ name={devices[item.deviceId].name}
+ coordinates={item.route}
+ />
+ ))}
+ <MapMarkers markers={createMarkers()} />
+ </MapView>
+ <MapCamera coordinates={items.flatMap((item) => item.route)} />
+ </div>
+ )}
+ <div className={classes.containerMain}>
+ <div className={classes.header}>
+ <ReportFilter handleSubmit={handleSubmit} showOnly multiDevice includeGroups />
+ </div>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedDevice')}</TableCell>
+ <TableCell>{t('positionFixTime')}</TableCell>
+ <TableCell>{t('sharedType')}</TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.flatMap((item) => item.events.map((event, index) => (
+ <TableRow key={event.id}>
+ <TableCell>{index ? '' : devices[item.deviceId].name}</TableCell>
+ <TableCell>{formatTime(event.eventTime, 'seconds', hours12)}</TableCell>
+ <TableCell>{t(prefixString('event', event.type))}</TableCell>
+ </TableRow>
+ ))) : (<TableShimmer columns={3} />)}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </PageLayout>
+ );
+};
+
+export default CombinedReportPage;
diff --git a/src/reports/EventReportPage.jsx b/src/reports/EventReportPage.jsx
new file mode 100644
index 00000000..5ffc8ac3
--- /dev/null
+++ b/src/reports/EventReportPage.jsx
@@ -0,0 +1,232 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Link, IconButton,
+} from '@mui/material';
+import GpsFixedIcon from '@mui/icons-material/GpsFixed';
+import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
+import { useSelector } from 'react-redux';
+import { formatSpeed, formatTime } from '../common/util/formatter';
+import ReportFilter from './components/ReportFilter';
+import { prefixString } from '../common/util/stringUtils';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import usePersistedState from '../common/util/usePersistedState';
+import ColumnSelect from './components/ColumnSelect';
+import { useCatch, useEffectAsync } from '../reactHelper';
+import useReportStyles from './common/useReportStyles';
+import TableShimmer from '../common/components/TableShimmer';
+import { useAttributePreference, usePreference } from '../common/util/preferences';
+import MapView from '../map/core/MapView';
+import MapGeofence from '../map/MapGeofence';
+import MapPositions from '../map/MapPositions';
+import MapCamera from '../map/MapCamera';
+import scheduleReport from './common/scheduleReport';
+
+const columnsArray = [
+ ['eventTime', 'positionFixTime'],
+ ['type', 'sharedType'],
+ ['geofenceId', 'sharedGeofence'],
+ ['maintenanceId', 'sharedMaintenance'],
+ ['attributes', 'commandData'],
+];
+const columnsMap = new Map(columnsArray);
+
+const EventReportPage = () => {
+ const navigate = useNavigate();
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const devices = useSelector((state) => state.devices.items);
+ const geofences = useSelector((state) => state.geofences.items);
+
+ const speedUnit = useAttributePreference('speedUnit');
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [allEventTypes, setAllEventTypes] = useState([['allEvents', 'eventAll']]);
+
+ const [columns, setColumns] = usePersistedState('eventColumns', ['eventTime', 'type', 'attributes']);
+ const [eventTypes, setEventTypes] = useState(['allEvents']);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [position, setPosition] = useState(null);
+
+ useEffectAsync(async () => {
+ if (selectedItem) {
+ const response = await fetch(`/api/positions?id=${selectedItem.positionId}`);
+ if (response.ok) {
+ const positions = await response.json();
+ if (positions.length > 0) {
+ setPosition(positions[0]);
+ }
+ } else {
+ throw Error(await response.text());
+ }
+ } else {
+ setPosition(null);
+ }
+ }, [selectedItem]);
+
+ useEffectAsync(async () => {
+ const response = await fetch('/api/notifications/types');
+ if (response.ok) {
+ const types = await response.json();
+ setAllEventTypes([...allEventTypes, ...types.map((it) => [it.type, prefixString('event', it.type)])]);
+ } else {
+ throw Error(await response.text());
+ }
+ }, []);
+
+ const handleSubmit = useCatch(async ({ deviceId, from, to, type }) => {
+ const query = new URLSearchParams({ deviceId, from, to });
+ eventTypes.forEach((it) => query.append('type', it));
+ if (type === 'export') {
+ window.location.assign(`/api/reports/events/xlsx?${query.toString()}`);
+ } else if (type === 'mail') {
+ const response = await fetch(`/api/reports/events/mail?${query.toString()}`);
+ if (!response.ok) {
+ throw Error(await response.text());
+ }
+ } else {
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/reports/events?${query.toString()}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+ });
+
+ const handleSchedule = useCatch(async (deviceIds, groupIds, report) => {
+ report.type = 'events';
+ if (eventTypes[0] !== 'allEvents') {
+ report.attributes.types = eventTypes.join(',');
+ }
+ const error = await scheduleReport(deviceIds, groupIds, report);
+ if (error) {
+ throw Error(error);
+ } else {
+ navigate('/reports/scheduled');
+ }
+ });
+
+ const formatValue = (item, key) => {
+ switch (key) {
+ case 'eventTime':
+ return formatTime(item[key], 'seconds', hours12);
+ case 'type':
+ return t(prefixString('event', item[key]));
+ case 'geofenceId':
+ if (item[key] > 0) {
+ const geofence = geofences[item[key]];
+ return geofence && geofence.name;
+ }
+ return null;
+ case 'maintenanceId':
+ return item[key] > 0 ? item[key] > 0 : null;
+ case 'attributes':
+ switch (item.type) {
+ case 'alarm':
+ return t(prefixString('alarm', item.attributes.alarm));
+ case 'deviceOverspeed':
+ return formatSpeed(item.attributes.speed, speedUnit, t);
+ case 'driverChanged':
+ return item.attributes.driverUniqueId;
+ case 'media':
+ return (<Link href={`/api/media/${devices[item.deviceId]?.uniqueId}/${item.attributes.file}`} target="_blank">{item.attributes.file}</Link>);
+ case 'commandResult':
+ return item.attributes.result;
+ default:
+ return '';
+ }
+ default:
+ return item[key];
+ }
+ };
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'reportEvents']}>
+ <div className={classes.container}>
+ {selectedItem && (
+ <div className={classes.containerMap}>
+ <MapView>
+ <MapGeofence />
+ {position && <MapPositions positions={[position]} titleField="fixTime" />}
+ </MapView>
+ {position && <MapCamera latitude={position.latitude} longitude={position.longitude} />}
+ </div>
+ )}
+ <div className={classes.containerMain}>
+ <div className={classes.header}>
+ <ReportFilter handleSubmit={handleSubmit} handleSchedule={handleSchedule}>
+ <div className={classes.filterItem}>
+ <FormControl fullWidth>
+ <InputLabel>{t('reportEventTypes')}</InputLabel>
+ <Select
+ label={t('reportEventTypes')}
+ value={eventTypes}
+ onChange={(event, child) => {
+ let values = event.target.value;
+ const clicked = child.props.value;
+ if (values.includes('allEvents') && values.length > 1) {
+ values = [clicked];
+ }
+ setEventTypes(values);
+ }}
+ multiple
+ >
+ {allEventTypes.map(([key, string]) => (
+ <MenuItem key={key} value={key}>{t(string)}</MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ </div>
+ <ColumnSelect columns={columns} setColumns={setColumns} columnsArray={columnsArray} />
+ </ReportFilter>
+ </div>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell className={classes.columnAction} />
+ {columns.map((key) => (<TableCell key={key}>{t(columnsMap.get(key))}</TableCell>))}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className={classes.columnAction} padding="none">
+ {(item.positionId && (selectedItem === item ? (
+ <IconButton size="small" onClick={() => setSelectedItem(null)}>
+ <GpsFixedIcon fontSize="small" />
+ </IconButton>
+ ) : (
+ <IconButton size="small" onClick={() => setSelectedItem(item)}>
+ <LocationSearchingIcon fontSize="small" />
+ </IconButton>
+ ))) || ''}
+ </TableCell>
+ {columns.map((key) => (
+ <TableCell key={key}>
+ {formatValue(item, key)}
+ </TableCell>
+ ))}
+ </TableRow>
+ )) : (<TableShimmer columns={columns.length + 1} />)}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </PageLayout>
+ );
+};
+
+export default EventReportPage;
diff --git a/src/reports/LogsPage.jsx b/src/reports/LogsPage.jsx
new file mode 100644
index 00000000..7bdbd309
--- /dev/null
+++ b/src/reports/LogsPage.jsx
@@ -0,0 +1,84 @@
+import React, { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody, IconButton, Tooltip,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
+import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import { sessionActions } from '../store';
+
+const useStyles = makeStyles((theme) => ({
+ columnAction: {
+ width: '1%',
+ paddingLeft: theme.spacing(1),
+ },
+ success: {
+ color: theme.palette.success.main,
+ },
+ error: {
+ color: theme.palette.error.main,
+ },
+}));
+
+const LogsPage = () => {
+ const classes = useStyles();
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ useEffect(() => {
+ dispatch(sessionActions.enableLogs(true));
+ return () => dispatch(sessionActions.enableLogs(false));
+ }, []);
+
+ const items = useSelector((state) => state.session.logs);
+
+ const registerDevice = (uniqueId) => {
+ const query = new URLSearchParams({ uniqueId });
+ navigate(`/settings/device?${query.toString()}`);
+ };
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'statisticsTitle']}>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell className={classes.columnAction} />
+ <TableCell>{t('deviceIdentifier')}</TableCell>
+ <TableCell>{t('positionProtocol')}</TableCell>
+ <TableCell>{t('commandData')}</TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {items.map((item, index) => /* eslint-disable react/no-array-index-key */ (
+ <TableRow key={index}>
+ <TableCell className={classes.columnAction} padding="none">
+ {item.deviceId ? (
+ <IconButton size="small" disabled>
+ <CheckCircleOutlineIcon fontSize="small" className={classes.success} />
+ </IconButton>
+ ) : (
+ <Tooltip title={t('loginRegister')}>
+ <IconButton size="small" onClick={() => registerDevice(item.uniqueId)}>
+ <HelpOutlineIcon fontSize="small" className={classes.error} />
+ </IconButton>
+ </Tooltip>
+ )}
+ </TableCell>
+ <TableCell>{item.uniqueId}</TableCell>
+ <TableCell>{item.protocol}</TableCell>
+ <TableCell>{item.data}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </PageLayout>
+ );
+};
+
+export default LogsPage;
diff --git a/src/reports/RouteReportPage.jsx b/src/reports/RouteReportPage.jsx
new file mode 100644
index 00000000..5003ff31
--- /dev/null
+++ b/src/reports/RouteReportPage.jsx
@@ -0,0 +1,173 @@
+import React, { Fragment, useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import {
+ IconButton, Table, TableBody, TableCell, TableHead, TableRow,
+} from '@mui/material';
+import GpsFixedIcon from '@mui/icons-material/GpsFixed';
+import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
+import ReportFilter from './components/ReportFilter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import PositionValue from '../common/components/PositionValue';
+import ColumnSelect from './components/ColumnSelect';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+import { useCatch } from '../reactHelper';
+import MapView from '../map/core/MapView';
+import MapRoutePath from '../map/MapRoutePath';
+import MapRoutePoints from '../map/MapRoutePoints';
+import MapPositions from '../map/MapPositions';
+import useReportStyles from './common/useReportStyles';
+import TableShimmer from '../common/components/TableShimmer';
+import MapCamera from '../map/MapCamera';
+import MapGeofence from '../map/MapGeofence';
+import scheduleReport from './common/scheduleReport';
+
+const RouteReportPage = () => {
+ const navigate = useNavigate();
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const devices = useSelector((state) => state.devices.items);
+
+ const [available, setAvailable] = useState([]);
+ const [columns, setColumns] = useState(['fixTime', 'latitude', 'longitude', 'speed', 'address']);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedItem, setSelectedItem] = useState(null);
+
+ const onMapPointClick = useCallback((positionId) => {
+ setSelectedItem(items.find((it) => it.id === positionId));
+ }, [items, setSelectedItem]);
+
+ const handleSubmit = useCatch(async ({ deviceIds, from, to, type }) => {
+ const query = new URLSearchParams({ from, to });
+ deviceIds.forEach((deviceId) => query.append('deviceId', deviceId));
+ if (type === 'export') {
+ window.location.assign(`/api/reports/route/xlsx?${query.toString()}`);
+ } else if (type === 'mail') {
+ const response = await fetch(`/api/reports/route/mail?${query.toString()}`);
+ if (!response.ok) {
+ throw Error(await response.text());
+ }
+ } else {
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/reports/route?${query.toString()}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (response.ok) {
+ const data = await response.json();
+ const keySet = new Set();
+ const keyList = [];
+ data.forEach((position) => {
+ Object.keys(position).forEach((it) => keySet.add(it));
+ Object.keys(position.attributes).forEach((it) => keySet.add(it));
+ });
+ ['id', 'deviceId', 'outdated', 'network', 'attributes'].forEach((key) => keySet.delete(key));
+ Object.keys(positionAttributes).forEach((key) => {
+ if (keySet.has(key)) {
+ keyList.push(key);
+ keySet.delete(key);
+ }
+ });
+ setAvailable([...keyList, ...keySet].map((key) => [key, positionAttributes[key]?.name || key]));
+ setItems(data);
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+ });
+
+ const handleSchedule = useCatch(async (deviceIds, groupIds, report) => {
+ report.type = 'route';
+ const error = await scheduleReport(deviceIds, groupIds, report);
+ if (error) {
+ throw Error(error);
+ } else {
+ navigate('/reports/scheduled');
+ }
+ });
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'reportRoute']}>
+ <div className={classes.container}>
+ {selectedItem && (
+ <div className={classes.containerMap}>
+ <MapView>
+ <MapGeofence />
+ {[...new Set(items.map((it) => it.deviceId))].map((deviceId) => {
+ const positions = items.filter((position) => position.deviceId === deviceId);
+ return (
+ <Fragment key={deviceId}>
+ <MapRoutePath positions={positions} />
+ <MapRoutePoints positions={positions} onClick={onMapPointClick} />
+ </Fragment>
+ );
+ })}
+ <MapPositions positions={[selectedItem]} titleField="fixTime" />
+ </MapView>
+ <MapCamera positions={items} />
+ </div>
+ )}
+ <div className={classes.containerMain}>
+ <div className={classes.header}>
+ <ReportFilter handleSubmit={handleSubmit} handleSchedule={handleSchedule} multiDevice>
+ <ColumnSelect
+ columns={columns}
+ setColumns={setColumns}
+ columnsArray={available}
+ rawValues
+ disabled={!items.length}
+ />
+ </ReportFilter>
+ </div>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell className={classes.columnAction} />
+ <TableCell>{t('sharedDevice')}</TableCell>
+ {columns.map((key) => (<TableCell key={key}>{positionAttributes[key]?.name || key}</TableCell>))}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.slice(0, 4000).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className={classes.columnAction} padding="none">
+ {selectedItem === item ? (
+ <IconButton size="small" onClick={() => setSelectedItem(null)}>
+ <GpsFixedIcon fontSize="small" />
+ </IconButton>
+ ) : (
+ <IconButton size="small" onClick={() => setSelectedItem(item)}>
+ <LocationSearchingIcon fontSize="small" />
+ </IconButton>
+ )}
+ </TableCell>
+ <TableCell>{devices[item.deviceId].name}</TableCell>
+ {columns.map((key) => (
+ <TableCell key={key}>
+ <PositionValue
+ position={item}
+ property={item.hasOwnProperty(key) ? key : null}
+ attribute={item.hasOwnProperty(key) ? null : key}
+ />
+ </TableCell>
+ ))}
+ </TableRow>
+ )) : (<TableShimmer columns={columns.length + 2} startAction />)}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </PageLayout>
+ );
+};
+
+export default RouteReportPage;
diff --git a/src/reports/ScheduledPage.jsx b/src/reports/ScheduledPage.jsx
new file mode 100644
index 00000000..50e335d5
--- /dev/null
+++ b/src/reports/ScheduledPage.jsx
@@ -0,0 +1,106 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody, IconButton,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import DeleteIcon from '@mui/icons-material/Delete';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import TableShimmer from '../common/components/TableShimmer';
+import RemoveDialog from '../common/components/RemoveDialog';
+
+const useStyles = makeStyles((theme) => ({
+ columnAction: {
+ width: '1%',
+ paddingRight: theme.spacing(1),
+ },
+}));
+
+const ScheduledPage = () => {
+ const classes = useStyles();
+ const t = useTranslation();
+
+ const calendars = useSelector((state) => state.calendars.items);
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [removingId, setRemovingId] = useState();
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/reports');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ const formatType = (type) => {
+ switch (type) {
+ case 'events':
+ return t('reportEvents');
+ case 'route':
+ return t('reportRoute');
+ case 'summary':
+ return t('reportSummary');
+ case 'trips':
+ return t('reportTrips');
+ case 'stops':
+ return t('reportStops');
+ default:
+ return type;
+ }
+ };
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['settingsTitle', 'reportScheduled']}>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedType')}</TableCell>
+ <TableCell>{t('sharedDescription')}</TableCell>
+ <TableCell>{t('sharedCalendar')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{formatType(item.type)}</TableCell>
+ <TableCell>{item.description}</TableCell>
+ <TableCell>{calendars[item.calendarId].name}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <IconButton size="small" onClick={() => setRemovingId(item.id)}>
+ <DeleteIcon fontSize="small" />
+ </IconButton>
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={4} endAction />)}
+ </TableBody>
+ </Table>
+ <RemoveDialog
+ style={{ transform: 'none' }}
+ open={!!removingId}
+ endpoint="reports"
+ itemId={removingId}
+ onResult={(removed) => {
+ setRemovingId(null);
+ if (removed) {
+ setTimestamp(Date.now());
+ }
+ }}
+ />
+ </PageLayout>
+ );
+};
+
+export default ScheduledPage;
diff --git a/src/reports/StatisticsPage.jsx b/src/reports/StatisticsPage.jsx
new file mode 100644
index 00000000..7b3f2879
--- /dev/null
+++ b/src/reports/StatisticsPage.jsx
@@ -0,0 +1,85 @@
+import React, { useState } from 'react';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import { formatTime } from '../common/util/formatter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import ReportFilter from './components/ReportFilter';
+import usePersistedState from '../common/util/usePersistedState';
+import ColumnSelect from './components/ColumnSelect';
+import { useCatch } from '../reactHelper';
+import useReportStyles from './common/useReportStyles';
+import TableShimmer from '../common/components/TableShimmer';
+import { usePreference } from '../common/util/preferences';
+
+const columnsArray = [
+ ['captureTime', 'statisticsCaptureTime'],
+ ['activeUsers', 'statisticsActiveUsers'],
+ ['activeDevices', 'statisticsActiveDevices'],
+ ['requests', 'statisticsRequests'],
+ ['messagesReceived', 'statisticsMessagesReceived'],
+ ['messagesStored', 'statisticsMessagesStored'],
+ ['mailSent', 'notificatorMail'],
+ ['smsSent', 'notificatorSms'],
+ ['geocoderRequests', 'statisticsGeocoder'],
+ ['geolocationRequests', 'statisticsGeolocation'],
+];
+const columnsMap = new Map(columnsArray);
+
+const StatisticsPage = () => {
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [columns, setColumns] = usePersistedState('statisticsColumns', ['captureTime', 'activeUsers', 'activeDevices', 'messagesStored']);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = useCatch(async ({ from, to }) => {
+ setLoading(true);
+ try {
+ const query = new URLSearchParams({ from, to });
+ const response = await fetch(`/api/statistics?${query.toString()}`);
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'statisticsTitle']}>
+ <div className={classes.header}>
+ <ReportFilter handleSubmit={handleSubmit} showOnly ignoreDevice>
+ <ColumnSelect columns={columns} setColumns={setColumns} columnsArray={columnsArray} />
+ </ReportFilter>
+ </div>
+ <Table>
+ <TableHead>
+ <TableRow>
+ {columns.map((key) => (<TableCell key={key}>{t(columnsMap.get(key))}</TableCell>))}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.map((item) => (
+ <TableRow key={item.id}>
+ {columns.map((key) => (
+ <TableCell key={key}>
+ {key === 'captureTime' ? formatTime(item[key], 'date', hours12) : item[key]}
+ </TableCell>
+ ))}
+ </TableRow>
+ )) : (<TableShimmer columns={columns.length} />)}
+ </TableBody>
+ </Table>
+ </PageLayout>
+ );
+};
+
+export default StatisticsPage;
diff --git a/src/reports/StopReportPage.jsx b/src/reports/StopReportPage.jsx
new file mode 100644
index 00000000..066b29a4
--- /dev/null
+++ b/src/reports/StopReportPage.jsx
@@ -0,0 +1,172 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ IconButton,
+ Table, TableBody, TableCell, TableHead, TableRow,
+} from '@mui/material';
+import GpsFixedIcon from '@mui/icons-material/GpsFixed';
+import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
+import {
+ formatDistance, formatVolume, formatTime, formatNumericHours,
+} from '../common/util/formatter';
+import ReportFilter from './components/ReportFilter';
+import { useAttributePreference, usePreference } from '../common/util/preferences';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import ColumnSelect from './components/ColumnSelect';
+import usePersistedState from '../common/util/usePersistedState';
+import { useCatch } from '../reactHelper';
+import useReportStyles from './common/useReportStyles';
+import MapPositions from '../map/MapPositions';
+import MapView from '../map/core/MapView';
+import MapCamera from '../map/MapCamera';
+import AddressValue from '../common/components/AddressValue';
+import TableShimmer from '../common/components/TableShimmer';
+import MapGeofence from '../map/MapGeofence';
+import scheduleReport from './common/scheduleReport';
+
+const columnsArray = [
+ ['startTime', 'reportStartTime'],
+ ['startOdometer', 'positionOdometer'],
+ ['address', 'positionAddress'],
+ ['endTime', 'reportEndTime'],
+ ['duration', 'reportDuration'],
+ ['engineHours', 'reportEngineHours'],
+ ['spentFuel', 'reportSpentFuel'],
+];
+const columnsMap = new Map(columnsArray);
+
+const StopReportPage = () => {
+ const navigate = useNavigate();
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const distanceUnit = useAttributePreference('distanceUnit');
+ const volumeUnit = useAttributePreference('volumeUnit');
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [columns, setColumns] = usePersistedState('stopColumns', ['startTime', 'endTime', 'startOdometer', 'address']);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedItem, setSelectedItem] = useState(null);
+
+ const handleSubmit = useCatch(async ({ deviceId, from, to, type }) => {
+ const query = new URLSearchParams({ deviceId, from, to });
+ if (type === 'export') {
+ window.location.assign(`/api/reports/stops/xlsx?${query.toString()}`);
+ } else if (type === 'mail') {
+ const response = await fetch(`/api/reports/stops/mail?${query.toString()}`);
+ if (!response.ok) {
+ throw Error(await response.text());
+ }
+ } else {
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/reports/stops?${query.toString()}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+ });
+
+ const handleSchedule = useCatch(async (deviceIds, groupIds, report) => {
+ report.type = 'stops';
+ const error = await scheduleReport(deviceIds, groupIds, report);
+ if (error) {
+ throw Error(error);
+ } else {
+ navigate('/reports/scheduled');
+ }
+ });
+
+ const formatValue = (item, key) => {
+ switch (key) {
+ case 'startTime':
+ case 'endTime':
+ return formatTime(item[key], 'minutes', hours12);
+ case 'startOdometer':
+ return formatDistance(item[key], distanceUnit, t);
+ case 'duration':
+ return formatNumericHours(item[key], t);
+ case 'engineHours':
+ return formatNumericHours(item[key], t);
+ case 'spentFuel':
+ return formatVolume(item[key], volumeUnit, t);
+ case 'address':
+ return (<AddressValue latitude={item.latitude} longitude={item.longitude} originalAddress={item[key]} />);
+ default:
+ return item[key];
+ }
+ };
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'reportStops']}>
+ <div className={classes.container}>
+ {selectedItem && (
+ <div className={classes.containerMap}>
+ <MapView>
+ <MapGeofence />
+ <MapPositions
+ positions={[{
+ deviceId: selectedItem.deviceId,
+ fixTime: selectedItem.startTime,
+ latitude: selectedItem.latitude,
+ longitude: selectedItem.longitude,
+ }]}
+ titleField="fixTime"
+ />
+ </MapView>
+ <MapCamera latitude={selectedItem.latitude} longitude={selectedItem.longitude} />
+ </div>
+ )}
+ <div className={classes.containerMain}>
+ <div className={classes.header}>
+ <ReportFilter handleSubmit={handleSubmit} handleSchedule={handleSchedule}>
+ <ColumnSelect columns={columns} setColumns={setColumns} columnsArray={columnsArray} />
+ </ReportFilter>
+ </div>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell className={classes.columnAction} />
+ {columns.map((key) => (<TableCell key={key}>{t(columnsMap.get(key))}</TableCell>))}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.map((item) => (
+ <TableRow key={item.positionId}>
+ <TableCell className={classes.columnAction} padding="none">
+ {selectedItem === item ? (
+ <IconButton size="small" onClick={() => setSelectedItem(null)}>
+ <GpsFixedIcon fontSize="small" />
+ </IconButton>
+ ) : (
+ <IconButton size="small" onClick={() => setSelectedItem(item)}>
+ <LocationSearchingIcon fontSize="small" />
+ </IconButton>
+ )}
+ </TableCell>
+ {columns.map((key) => (
+ <TableCell key={key}>
+ {formatValue(item, key)}
+ </TableCell>
+ ))}
+ </TableRow>
+ )) : (<TableShimmer columns={columns.length + 1} startAction />)}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </PageLayout>
+ );
+};
+
+export default StopReportPage;
diff --git a/src/reports/SummaryReportPage.jsx b/src/reports/SummaryReportPage.jsx
new file mode 100644
index 00000000..ae7e043e
--- /dev/null
+++ b/src/reports/SummaryReportPage.jsx
@@ -0,0 +1,152 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import {
+ FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableBody, TableCell,
+} from '@mui/material';
+import {
+ formatDistance, formatSpeed, formatVolume, formatTime, formatNumericHours,
+} from '../common/util/formatter';
+import ReportFilter from './components/ReportFilter';
+import { useAttributePreference, usePreference } from '../common/util/preferences';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import usePersistedState from '../common/util/usePersistedState';
+import ColumnSelect from './components/ColumnSelect';
+import { useCatch } from '../reactHelper';
+import useReportStyles from './common/useReportStyles';
+import TableShimmer from '../common/components/TableShimmer';
+import scheduleReport from './common/scheduleReport';
+
+const columnsArray = [
+ ['startTime', 'reportStartDate'],
+ ['distance', 'sharedDistance'],
+ ['startOdometer', 'reportStartOdometer'],
+ ['endOdometer', 'reportEndOdometer'],
+ ['averageSpeed', 'reportAverageSpeed'],
+ ['maxSpeed', 'reportMaximumSpeed'],
+ ['engineHours', 'reportEngineHours'],
+ ['spentFuel', 'reportSpentFuel'],
+];
+const columnsMap = new Map(columnsArray);
+
+const SummaryReportPage = () => {
+ const navigate = useNavigate();
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const devices = useSelector((state) => state.devices.items);
+
+ const distanceUnit = useAttributePreference('distanceUnit');
+ const speedUnit = useAttributePreference('speedUnit');
+ const volumeUnit = useAttributePreference('volumeUnit');
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [columns, setColumns] = usePersistedState('summaryColumns', ['startTime', 'distance', 'averageSpeed']);
+ const [daily, setDaily] = useState(false);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = useCatch(async ({ deviceIds, groupIds, from, to, type }) => {
+ const query = new URLSearchParams({ from, to, daily });
+ deviceIds.forEach((deviceId) => query.append('deviceId', deviceId));
+ groupIds.forEach((groupId) => query.append('groupId', groupId));
+ if (type === 'export') {
+ window.location.assign(`/api/reports/summary/xlsx?${query.toString()}`);
+ } else if (type === 'mail') {
+ const response = await fetch(`/api/reports/summary/mail?${query.toString()}`);
+ if (!response.ok) {
+ throw Error(await response.text());
+ }
+ } else {
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/reports/summary?${query.toString()}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+ });
+
+ const handleSchedule = useCatch(async (deviceIds, groupIds, report) => {
+ report.type = 'summary';
+ report.attributes.daily = daily;
+ const error = await scheduleReport(deviceIds, groupIds, report);
+ if (error) {
+ throw Error(error);
+ } else {
+ navigate('/reports/scheduled');
+ }
+ });
+
+ const formatValue = (item, key) => {
+ switch (key) {
+ case 'deviceId':
+ return devices[item[key]].name;
+ case 'startTime':
+ return formatTime(item[key], 'date', hours12);
+ case 'startOdometer':
+ case 'endOdometer':
+ case 'distance':
+ return formatDistance(item[key], distanceUnit, t);
+ case 'averageSpeed':
+ case 'maxSpeed':
+ return formatSpeed(item[key], speedUnit, t);
+ case 'engineHours':
+ return formatNumericHours(item[key], t);
+ case 'spentFuel':
+ return formatVolume(item[key], volumeUnit, t);
+ default:
+ return item[key];
+ }
+ };
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'reportSummary']}>
+ <div className={classes.header}>
+ <ReportFilter handleSubmit={handleSubmit} handleSchedule={handleSchedule} multiDevice includeGroups>
+ <div className={classes.filterItem}>
+ <FormControl fullWidth>
+ <InputLabel>{t('sharedType')}</InputLabel>
+ <Select label={t('sharedType')} value={daily} onChange={(e) => setDaily(e.target.value)}>
+ <MenuItem value={false}>{t('reportSummary')}</MenuItem>
+ <MenuItem value>{t('reportDaily')}</MenuItem>
+ </Select>
+ </FormControl>
+ </div>
+ <ColumnSelect columns={columns} setColumns={setColumns} columnsArray={columnsArray} />
+ </ReportFilter>
+ </div>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedDevice')}</TableCell>
+ {columns.map((key) => (<TableCell key={key}>{t(columnsMap.get(key))}</TableCell>))}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.map((item) => (
+ <TableRow key={(`${item.deviceId}_${Date.parse(item.startTime)}`)}>
+ <TableCell>{devices[item.deviceId].name}</TableCell>
+ {columns.map((key) => (
+ <TableCell key={key}>
+ {formatValue(item, key)}
+ </TableCell>
+ ))}
+ </TableRow>
+ )) : (<TableShimmer columns={columns.length + 1} />)}
+ </TableBody>
+ </Table>
+ </PageLayout>
+ );
+};
+
+export default SummaryReportPage;
diff --git a/src/reports/TripReportPage.jsx b/src/reports/TripReportPage.jsx
new file mode 100644
index 00000000..897ee506
--- /dev/null
+++ b/src/reports/TripReportPage.jsx
@@ -0,0 +1,216 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ IconButton, Table, TableBody, TableCell, TableHead, TableRow,
+} from '@mui/material';
+import GpsFixedIcon from '@mui/icons-material/GpsFixed';
+import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
+import {
+ formatDistance, formatSpeed, formatVolume, formatTime, formatNumericHours,
+} from '../common/util/formatter';
+import ReportFilter from './components/ReportFilter';
+import { useAttributePreference, usePreference } from '../common/util/preferences';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import ReportsMenu from './components/ReportsMenu';
+import ColumnSelect from './components/ColumnSelect';
+import usePersistedState from '../common/util/usePersistedState';
+import { useCatch, useEffectAsync } from '../reactHelper';
+import useReportStyles from './common/useReportStyles';
+import MapView from '../map/core/MapView';
+import MapRoutePath from '../map/MapRoutePath';
+import AddressValue from '../common/components/AddressValue';
+import TableShimmer from '../common/components/TableShimmer';
+import MapMarkers from '../map/MapMarkers';
+import MapCamera from '../map/MapCamera';
+import MapGeofence from '../map/MapGeofence';
+import scheduleReport from './common/scheduleReport';
+
+const columnsArray = [
+ ['startTime', 'reportStartTime'],
+ ['startOdometer', 'reportStartOdometer'],
+ ['startAddress', 'reportStartAddress'],
+ ['endTime', 'reportEndTime'],
+ ['endOdometer', 'reportEndOdometer'],
+ ['endAddress', 'reportEndAddress'],
+ ['distance', 'sharedDistance'],
+ ['averageSpeed', 'reportAverageSpeed'],
+ ['maxSpeed', 'reportMaximumSpeed'],
+ ['duration', 'reportDuration'],
+ ['spentFuel', 'reportSpentFuel'],
+ ['driverName', 'sharedDriver'],
+];
+const columnsMap = new Map(columnsArray);
+
+const TripReportPage = () => {
+ const navigate = useNavigate();
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ const distanceUnit = useAttributePreference('distanceUnit');
+ const speedUnit = useAttributePreference('speedUnit');
+ const volumeUnit = useAttributePreference('volumeUnit');
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [columns, setColumns] = usePersistedState('tripColumns', ['startTime', 'endTime', 'distance', 'averageSpeed']);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [route, setRoute] = useState(null);
+
+ const createMarkers = () => ([
+ {
+ latitude: selectedItem.startLat,
+ longitude: selectedItem.startLon,
+ image: 'default-error',
+ },
+ {
+ latitude: selectedItem.endLat,
+ longitude: selectedItem.endLon,
+ image: 'default-success',
+ },
+ ]);
+
+ useEffectAsync(async () => {
+ if (selectedItem) {
+ const query = new URLSearchParams({
+ deviceId: selectedItem.deviceId,
+ from: selectedItem.startTime,
+ to: selectedItem.endTime,
+ });
+ const response = await fetch(`/api/reports/route?${query.toString()}`, {
+ headers: {
+ Accept: 'application/json',
+ },
+ });
+ if (response.ok) {
+ setRoute(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } else {
+ setRoute(null);
+ }
+ }, [selectedItem]);
+
+ const handleSubmit = useCatch(async ({ deviceId, from, to, type }) => {
+ const query = new URLSearchParams({ deviceId, from, to });
+ if (type === 'export') {
+ window.location.assign(`/api/reports/trips/xlsx?${query.toString()}`);
+ } else if (type === 'mail') {
+ const response = await fetch(`/api/reports/trips/mail?${query.toString()}`);
+ if (!response.ok) {
+ throw Error(await response.text());
+ }
+ } else {
+ setLoading(true);
+ try {
+ const response = await fetch(`/api/reports/trips?${query.toString()}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+ });
+
+ const handleSchedule = useCatch(async (deviceIds, groupIds, report) => {
+ report.type = 'trips';
+ const error = await scheduleReport(deviceIds, groupIds, report);
+ if (error) {
+ throw Error(error);
+ } else {
+ navigate('/reports/scheduled');
+ }
+ });
+
+ const formatValue = (item, key) => {
+ switch (key) {
+ case 'startTime':
+ case 'endTime':
+ return formatTime(item[key], 'minutes', hours12);
+ case 'startOdometer':
+ case 'endOdometer':
+ case 'distance':
+ return formatDistance(item[key], distanceUnit, t);
+ case 'averageSpeed':
+ case 'maxSpeed':
+ return formatSpeed(item[key], speedUnit, t);
+ case 'duration':
+ return formatNumericHours(item[key], t);
+ case 'spentFuel':
+ return formatVolume(item[key], volumeUnit, t);
+ case 'startAddress':
+ return (<AddressValue latitude={item.startLat} longitude={item.startLon} originalAddress={item[key]} />);
+ case 'endAddress':
+ return (<AddressValue latitude={item.endLat} longitude={item.endLon} originalAddress={item[key]} />);
+ default:
+ return item[key];
+ }
+ };
+
+ return (
+ <PageLayout menu={<ReportsMenu />} breadcrumbs={['reportTitle', 'reportTrips']}>
+ <div className={classes.container}>
+ {selectedItem && (
+ <div className={classes.containerMap}>
+ <MapView>
+ <MapGeofence />
+ {route && (
+ <>
+ <MapRoutePath positions={route} />
+ <MapMarkers markers={createMarkers()} />
+ <MapCamera positions={route} />
+ </>
+ )}
+ </MapView>
+ </div>
+ )}
+ <div className={classes.containerMain}>
+ <div className={classes.header}>
+ <ReportFilter handleSubmit={handleSubmit} handleSchedule={handleSchedule}>
+ <ColumnSelect columns={columns} setColumns={setColumns} columnsArray={columnsArray} />
+ </ReportFilter>
+ </div>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell className={classes.columnAction} />
+ {columns.map((key) => (<TableCell key={key}>{t(columnsMap.get(key))}</TableCell>))}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.map((item) => (
+ <TableRow key={item.startPositionId}>
+ <TableCell className={classes.columnAction} padding="none">
+ {selectedItem === item ? (
+ <IconButton size="small" onClick={() => setSelectedItem(null)}>
+ <GpsFixedIcon fontSize="small" />
+ </IconButton>
+ ) : (
+ <IconButton size="small" onClick={() => setSelectedItem(item)}>
+ <LocationSearchingIcon fontSize="small" />
+ </IconButton>
+ )}
+ </TableCell>
+ {columns.map((key) => (
+ <TableCell key={key}>
+ {formatValue(item, key)}
+ </TableCell>
+ ))}
+ </TableRow>
+ )) : (<TableShimmer columns={columns.length + 1} startAction />)}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </PageLayout>
+ );
+};
+
+export default TripReportPage;
diff --git a/src/reports/common/scheduleReport.js b/src/reports/common/scheduleReport.js
new file mode 100644
index 00000000..5d8f9e28
--- /dev/null
+++ b/src/reports/common/scheduleReport.js
@@ -0,0 +1,26 @@
+export default async (deviceIds, groupIds, report) => {
+ const response = await fetch('/api/reports', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(report),
+ });
+ if (response.ok) {
+ report = await response.json();
+ if (deviceIds.length) {
+ await fetch('/api/permissions/bulk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(deviceIds.map((id) => ({ deviceId: id, reportId: report.id }))),
+ });
+ }
+ if (groupIds.length) {
+ await fetch('/api/permissions/bulk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(groupIds.map((id) => ({ groupId: id, reportId: report.id }))),
+ });
+ }
+ return null;
+ }
+ return response.text();
+};
diff --git a/src/reports/common/useReportStyles.js b/src/reports/common/useReportStyles.js
new file mode 100644
index 00000000..e09c8695
--- /dev/null
+++ b/src/reports/common/useReportStyles.js
@@ -0,0 +1,49 @@
+import { makeStyles } from '@mui/styles';
+
+export default makeStyles((theme) => ({
+ container: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ containerMap: {
+ flexBasis: '40%',
+ flexShrink: 0,
+ },
+ containerMain: {
+ overflow: 'auto',
+ },
+ header: {
+ position: 'sticky',
+ left: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ },
+ columnAction: {
+ width: '1%',
+ paddingLeft: theme.spacing(1),
+ },
+ filter: {
+ display: 'inline-flex',
+ flexWrap: 'wrap',
+ gap: theme.spacing(2),
+ padding: theme.spacing(3, 2, 2),
+ },
+ filterItem: {
+ minWidth: 0,
+ flex: `1 1 ${theme.dimensions.filterFormWidth}`,
+ },
+ filterButtons: {
+ display: 'flex',
+ gap: theme.spacing(1),
+ flex: `1 1 ${theme.dimensions.filterFormWidth}`,
+ },
+ filterButton: {
+ flexGrow: 1,
+ },
+ chart: {
+ flexGrow: 1,
+ overflow: 'hidden',
+ },
+}));
diff --git a/src/reports/components/ColumnSelect.jsx b/src/reports/components/ColumnSelect.jsx
new file mode 100644
index 00000000..d08394ea
--- /dev/null
+++ b/src/reports/components/ColumnSelect.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import {
+ FormControl, InputLabel, MenuItem, Select,
+} from '@mui/material';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import useReportStyles from '../common/useReportStyles';
+
+const ColumnSelect = ({
+ columns, setColumns, columnsArray, rawValues, disabled,
+}) => {
+ const classes = useReportStyles();
+ const t = useTranslation();
+
+ return (
+ <div className={classes.filterItem}>
+ <FormControl fullWidth>
+ <InputLabel>{t('sharedColumns')}</InputLabel>
+ <Select
+ label={t('sharedColumns')}
+ value={columns}
+ onChange={(e) => setColumns(e.target.value)}
+ multiple
+ disabled={disabled}
+ >
+ {columnsArray.map(([key, string]) => (
+ <MenuItem key={key} value={key}>{rawValues ? string : t(string)}</MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ </div>
+ );
+};
+
+export default ColumnSelect;
diff --git a/src/reports/components/ReportFilter.jsx b/src/reports/components/ReportFilter.jsx
new file mode 100644
index 00000000..e6e08f16
--- /dev/null
+++ b/src/reports/components/ReportFilter.jsx
@@ -0,0 +1,215 @@
+import React, { useState } from 'react';
+import {
+ FormControl, InputLabel, Select, MenuItem, Button, TextField, Typography,
+} from '@mui/material';
+import { useDispatch, useSelector } from 'react-redux';
+import dayjs from 'dayjs';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import useReportStyles from '../common/useReportStyles';
+import { devicesActions, reportsActions } from '../../store';
+import SplitButton from '../../common/components/SplitButton';
+import SelectField from '../../common/components/SelectField';
+import { useRestriction } from '../../common/util/permissions';
+
+const ReportFilter = ({ children, handleSubmit, handleSchedule, showOnly, ignoreDevice, multiDevice, includeGroups }) => {
+ const classes = useReportStyles();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const readonly = useRestriction('readonly');
+
+ const devices = useSelector((state) => state.devices.items);
+ const groups = useSelector((state) => state.groups.items);
+
+ const deviceId = useSelector((state) => state.devices.selectedId);
+ const deviceIds = useSelector((state) => state.devices.selectedIds);
+ const groupIds = useSelector((state) => state.reports.groupIds);
+ const period = useSelector((state) => state.reports.period);
+ const from = useSelector((state) => state.reports.from);
+ const to = useSelector((state) => state.reports.to);
+ const [button, setButton] = useState('json');
+
+ const [description, setDescription] = useState();
+ const [calendarId, setCalendarId] = useState();
+
+ const scheduleDisabled = button === 'schedule' && (!description || !calendarId);
+ const disabled = (!ignoreDevice && !deviceId && !deviceIds.length && !groupIds.length) || scheduleDisabled;
+
+ const handleClick = (type) => {
+ if (type === 'schedule') {
+ handleSchedule(deviceIds, groupIds, {
+ description,
+ calendarId,
+ attributes: {},
+ });
+ } else {
+ let selectedFrom;
+ let selectedTo;
+ switch (period) {
+ case 'today':
+ selectedFrom = dayjs().startOf('day');
+ selectedTo = dayjs().endOf('day');
+ break;
+ case 'yesterday':
+ selectedFrom = dayjs().subtract(1, 'day').startOf('day');
+ selectedTo = dayjs().subtract(1, 'day').endOf('day');
+ break;
+ case 'thisWeek':
+ selectedFrom = dayjs().startOf('week');
+ selectedTo = dayjs().endOf('week');
+ break;
+ case 'previousWeek':
+ selectedFrom = dayjs().subtract(1, 'week').startOf('week');
+ selectedTo = dayjs().subtract(1, 'week').endOf('week');
+ break;
+ case 'thisMonth':
+ selectedFrom = dayjs().startOf('month');
+ selectedTo = dayjs().endOf('month');
+ break;
+ case 'previousMonth':
+ selectedFrom = dayjs().subtract(1, 'month').startOf('month');
+ selectedTo = dayjs().subtract(1, 'month').endOf('month');
+ break;
+ default:
+ selectedFrom = dayjs(from, 'YYYY-MM-DDTHH:mm');
+ selectedTo = dayjs(to, 'YYYY-MM-DDTHH:mm');
+ break;
+ }
+
+ handleSubmit({
+ deviceId,
+ deviceIds,
+ groupIds,
+ from: selectedFrom.toISOString(),
+ to: selectedTo.toISOString(),
+ calendarId,
+ type,
+ });
+ }
+ };
+
+ return (
+ <div className={classes.filter}>
+ {!ignoreDevice && (
+ <div className={classes.filterItem}>
+ <SelectField
+ label={t(multiDevice ? 'deviceTitle' : 'reportDevice')}
+ data={Object.values(devices).sort((a, b) => a.name.localeCompare(b.name))}
+ value={multiDevice ? deviceIds : deviceId}
+ onChange={(e) => dispatch(multiDevice ? devicesActions.selectIds(e.target.value) : devicesActions.selectId(e.target.value))}
+ multiple={multiDevice}
+ fullWidth
+ />
+ </div>
+ )}
+ {includeGroups && (
+ <div className={classes.filterItem}>
+ <SelectField
+ label={t('settingsGroups')}
+ data={Object.values(groups).sort((a, b) => a.name.localeCompare(b.name))}
+ value={groupIds}
+ onChange={(e) => dispatch(reportsActions.updateGroupIds(e.target.value))}
+ multiple
+ fullWidth
+ />
+ </div>
+ )}
+ {button !== 'schedule' ? (
+ <>
+ <div className={classes.filterItem}>
+ <FormControl fullWidth>
+ <InputLabel>{t('reportPeriod')}</InputLabel>
+ <Select label={t('reportPeriod')} value={period} onChange={(e) => dispatch(reportsActions.updatePeriod(e.target.value))}>
+ <MenuItem value="today">{t('reportToday')}</MenuItem>
+ <MenuItem value="yesterday">{t('reportYesterday')}</MenuItem>
+ <MenuItem value="thisWeek">{t('reportThisWeek')}</MenuItem>
+ <MenuItem value="previousWeek">{t('reportPreviousWeek')}</MenuItem>
+ <MenuItem value="thisMonth">{t('reportThisMonth')}</MenuItem>
+ <MenuItem value="previousMonth">{t('reportPreviousMonth')}</MenuItem>
+ <MenuItem value="custom">{t('reportCustom')}</MenuItem>
+ </Select>
+ </FormControl>
+ </div>
+ {period === 'custom' && (
+ <div className={classes.filterItem}>
+ <TextField
+ label={t('reportFrom')}
+ type="datetime-local"
+ value={from}
+ onChange={(e) => dispatch(reportsActions.updateFrom(e.target.value))}
+ fullWidth
+ />
+ </div>
+ )}
+ {period === 'custom' && (
+ <div className={classes.filterItem}>
+ <TextField
+ label={t('reportTo')}
+ type="datetime-local"
+ value={to}
+ onChange={(e) => dispatch(reportsActions.updateTo(e.target.value))}
+ fullWidth
+ />
+ </div>
+ )}
+ </>
+ ) : (
+ <>
+ <div className={classes.filterItem}>
+ <TextField
+ value={description || ''}
+ onChange={(event) => setDescription(event.target.value)}
+ label={t('sharedDescription')}
+ fullWidth
+ />
+ </div>
+ <div className={classes.filterItem}>
+ <SelectField
+ value={calendarId}
+ onChange={(event) => setCalendarId(Number(event.target.value))}
+ endpoint="/api/calendars"
+ label={t('sharedCalendar')}
+ fullWidth
+ />
+ </div>
+ </>
+ )}
+ {children}
+ <div className={classes.filterItem}>
+ {showOnly ? (
+ <Button
+ fullWidth
+ variant="outlined"
+ color="secondary"
+ disabled={disabled}
+ onClick={() => handleClick('json')}
+ >
+ <Typography variant="button" noWrap>{t('reportShow')}</Typography>
+ </Button>
+ ) : (
+ <SplitButton
+ fullWidth
+ variant="outlined"
+ color="secondary"
+ disabled={disabled}
+ onClick={handleClick}
+ selected={button}
+ setSelected={(value) => setButton(value)}
+ options={readonly ? {
+ json: t('reportShow'),
+ export: t('reportExport'),
+ mail: t('reportEmail'),
+ } : {
+ json: t('reportShow'),
+ export: t('reportExport'),
+ mail: t('reportEmail'),
+ schedule: t('reportSchedule'),
+ }}
+ />
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default ReportFilter;
diff --git a/src/reports/components/ReportsMenu.jsx b/src/reports/components/ReportsMenu.jsx
new file mode 100644
index 00000000..a45a4500
--- /dev/null
+++ b/src/reports/components/ReportsMenu.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import {
+ Divider, List, ListItemButton, ListItemIcon, ListItemText,
+} from '@mui/material';
+import StarIcon from '@mui/icons-material/Star';
+import TimelineIcon from '@mui/icons-material/Timeline';
+import PauseCircleFilledIcon from '@mui/icons-material/PauseCircleFilled';
+import PlayCircleFilledIcon from '@mui/icons-material/PlayCircleFilled';
+import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
+import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
+import TrendingUpIcon from '@mui/icons-material/TrendingUp';
+import BarChartIcon from '@mui/icons-material/BarChart';
+import RouteIcon from '@mui/icons-material/Route';
+import EventRepeatIcon from '@mui/icons-material/EventRepeat';
+import NotesIcon from '@mui/icons-material/Notes';
+import { Link, useLocation } from 'react-router-dom';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import { useAdministrator, useRestriction } from '../../common/util/permissions';
+
+const MenuItem = ({
+ title, link, icon, selected,
+}) => (
+ <ListItemButton key={link} component={Link} to={link} selected={selected}>
+ <ListItemIcon>{icon}</ListItemIcon>
+ <ListItemText primary={title} />
+ </ListItemButton>
+);
+
+const ReportsMenu = () => {
+ const t = useTranslation();
+ const location = useLocation();
+
+ const admin = useAdministrator();
+ const readonly = useRestriction('readonly');
+
+ return (
+ <>
+ <List>
+ <MenuItem
+ title={t('reportCombined')}
+ link="/reports/combined"
+ icon={<StarIcon />}
+ selected={location.pathname === '/reports/combined'}
+ />
+ <MenuItem
+ title={t('reportRoute')}
+ link="/reports/route"
+ icon={<TimelineIcon />}
+ selected={location.pathname === '/reports/route'}
+ />
+ <MenuItem
+ title={t('reportEvents')}
+ link="/reports/event"
+ icon={<NotificationsActiveIcon />}
+ selected={location.pathname === '/reports/event'}
+ />
+ <MenuItem
+ title={t('reportTrips')}
+ link="/reports/trip"
+ icon={<PlayCircleFilledIcon />}
+ selected={location.pathname === '/reports/trip'}
+ />
+ <MenuItem
+ title={t('reportStops')}
+ link="/reports/stop"
+ icon={<PauseCircleFilledIcon />}
+ selected={location.pathname === '/reports/stop'}
+ />
+ <MenuItem
+ title={t('reportSummary')}
+ link="/reports/summary"
+ icon={<FormatListBulletedIcon />}
+ selected={location.pathname === '/reports/summary'}
+ />
+ <MenuItem
+ title={t('reportChart')}
+ link="/reports/chart"
+ icon={<TrendingUpIcon />}
+ selected={location.pathname === '/reports/chart'}
+ />
+ <MenuItem
+ title={t('reportReplay')}
+ link="/replay"
+ icon={<RouteIcon />}
+ />
+ </List>
+ <Divider />
+ <List>
+ <MenuItem
+ title={t('sharedLogs')}
+ link="/reports/logs"
+ icon={<NotesIcon />}
+ selected={location.pathname === '/reports/logs'}
+ />
+ {!readonly && (
+ <MenuItem
+ title={t('reportScheduled')}
+ link="/reports/scheduled"
+ icon={<EventRepeatIcon />}
+ selected={location.pathname === '/reports/scheduled'}
+ />
+ )}
+ {admin && (
+ <MenuItem
+ title={t('statisticsTitle')}
+ link="/reports/statistics"
+ icon={<BarChartIcon />}
+ selected={location.pathname === '/reports/statistics'}
+ />
+ )}
+ </List>
+ </>
+ );
+};
+
+export default ReportsMenu;