diff options
Diffstat (limited to 'src/reports')
-rw-r--r-- | src/reports/ChartReportPage.jsx | 152 | ||||
-rw-r--r-- | src/reports/CombinedReportPage.jsx | 105 | ||||
-rw-r--r-- | src/reports/EventReportPage.jsx | 232 | ||||
-rw-r--r-- | src/reports/LogsPage.jsx | 84 | ||||
-rw-r--r-- | src/reports/RouteReportPage.jsx | 173 | ||||
-rw-r--r-- | src/reports/ScheduledPage.jsx | 106 | ||||
-rw-r--r-- | src/reports/StatisticsPage.jsx | 85 | ||||
-rw-r--r-- | src/reports/StopReportPage.jsx | 172 | ||||
-rw-r--r-- | src/reports/SummaryReportPage.jsx | 152 | ||||
-rw-r--r-- | src/reports/TripReportPage.jsx | 216 | ||||
-rw-r--r-- | src/reports/common/scheduleReport.js | 26 | ||||
-rw-r--r-- | src/reports/common/useReportStyles.js | 49 | ||||
-rw-r--r-- | src/reports/components/ColumnSelect.jsx | 34 | ||||
-rw-r--r-- | src/reports/components/ReportFilter.jsx | 215 | ||||
-rw-r--r-- | src/reports/components/ReportsMenu.jsx | 116 |
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; |