diff options
Diffstat (limited to 'src/settings')
38 files changed, 4852 insertions, 0 deletions
diff --git a/src/settings/AccumulatorsPage.jsx b/src/settings/AccumulatorsPage.jsx new file mode 100644 index 00000000..1c9b6e65 --- /dev/null +++ b/src/settings/AccumulatorsPage.jsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, + TextField, + Button, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import { useCatch } from '../reactHelper'; +import { useAttributePreference } from '../common/util/preferences'; +import { distanceFromMeters, distanceToMeters, distanceUnitString } from '../common/util/converter'; +import useSettingsStyles from './common/useSettingsStyles'; + +const AccumulatorsPage = () => { + const navigate = useNavigate(); + const classes = useSettingsStyles(); + const t = useTranslation(); + + const distanceUnit = useAttributePreference('distanceUnit'); + + const { deviceId } = useParams(); + const position = useSelector((state) => state.session.positions[deviceId]); + + const [item, setItem] = useState(); + + useEffect(() => { + if (position) { + setItem({ + deviceId: parseInt(deviceId, 10), + hours: position.attributes.hours || 0, + totalDistance: position.attributes.totalDistance || 0, + }); + } + }, [deviceId, position]); + + const handleSave = useCatch(async () => { + const response = await fetch(`/api/devices/${deviceId}/accumulators`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item), + }); + + if (response.ok) { + navigate(-1); + } else { + throw Error(await response.text()); + } + }); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['sharedDeviceAccumulators']}> + {item && ( + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + type="number" + value={item.hours / 3600000} + onChange={(event) => setItem({ ...item, hours: Number(event.target.value) * 3600000 })} + label={t('positionHours')} + /> + <TextField + type="number" + value={distanceFromMeters(item.totalDistance, distanceUnit)} + onChange={(event) => setItem({ ...item, totalDistance: distanceToMeters(Number(event.target.value), distanceUnit) })} + label={`${t('deviceTotalDistance')} (${distanceUnitString(distanceUnit, t)})`} + /> + </AccordionDetails> + </Accordion> + <div className={classes.buttons}> + <Button + type="button" + color="primary" + variant="outlined" + onClick={() => navigate(-1)} + > + {t('sharedCancel')} + </Button> + <Button + type="button" + color="primary" + variant="contained" + onClick={handleSave} + > + {t('sharedSave')} + </Button> + </div> + </Container> + )} + </PageLayout> + ); +}; + +export default AccumulatorsPage; diff --git a/src/settings/AnnouncementPage.jsx b/src/settings/AnnouncementPage.jsx new file mode 100644 index 00000000..39488f02 --- /dev/null +++ b/src/settings/AnnouncementPage.jsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, + TextField, + Button, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import { useCatchCallback } from '../reactHelper'; +import useSettingsStyles from './common/useSettingsStyles'; +import SelectField from '../common/components/SelectField'; +import { prefixString } from '../common/util/stringUtils'; + +const AnnouncementPage = () => { + const navigate = useNavigate(); + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [users, setUsers] = useState([]); + const [notificator, setNotificator] = useState(); + const [message, setMessage] = useState({}); + + const handleSend = useCatchCallback(async () => { + const query = new URLSearchParams(); + users.forEach((userId) => query.append('userId', userId)); + const response = await fetch(`/api/notifications/send/${notificator}?${query.toString()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message), + }); + if (response.ok) { + navigate(-1); + } else { + throw Error(await response.text()); + } + }, [users, notificator, message, navigate]); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['serverAnnouncement']}> + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + multiple + value={users} + onChange={(e) => setUsers(e.target.value)} + endpoint="/api/users" + label={t('settingsUsers')} + /> + <SelectField + value={notificator} + onChange={(e) => setNotificator(e.target.value)} + endpoint="/api/notifications/notificators" + keyGetter={(it) => it.type} + titleGetter={(it) => t(prefixString('notificator', it.type))} + label={t('notificationNotificators')} + /> + <TextField + value={message.subject} + onChange={(e) => setMessage({ ...message, subject: e.target.value })} + label={t('sharedSubject')} + /> + <TextField + value={message.body} + onChange={(e) => setMessage({ ...message, body: e.target.value })} + label={t('commandMessage')} + /> + </AccordionDetails> + </Accordion> + <div className={classes.buttons}> + <Button + type="button" + color="primary" + variant="outlined" + onClick={() => navigate(-1)} + > + {t('sharedCancel')} + </Button> + <Button + type="button" + color="primary" + variant="contained" + onClick={handleSend} + disabled={!notificator || !message.subject || !message.body} + > + {t('commandSend')} + </Button> + </div> + </Container> + </PageLayout> + ); +}; + +export default AnnouncementPage; diff --git a/src/settings/CalendarPage.jsx b/src/settings/CalendarPage.jsx new file mode 100644 index 00000000..8a3dc986 --- /dev/null +++ b/src/settings/CalendarPage.jsx @@ -0,0 +1,208 @@ +import dayjs from 'dayjs'; +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import TextField from '@mui/material/TextField'; +import { + Accordion, AccordionSummary, AccordionDetails, Typography, FormControl, InputLabel, Select, MenuItem, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { DropzoneArea } from 'react-mui-dropzone'; +import EditItemView from './components/EditItemView'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import SettingsMenu from './components/SettingsMenu'; +import { prefixString } from '../common/util/stringUtils'; +import { calendarsActions } from '../store'; +import { useCatch } from '../reactHelper'; +import useSettingsStyles from './common/useSettingsStyles'; + +const formatCalendarTime = (time) => { + const tzid = Intl.DateTimeFormat().resolvedOptions().timeZone; + return `TZID=${tzid}:${time.locale('en').format('YYYYMMDDTHHmmss')}`; +}; + +const parseRule = (rule) => { + if (rule.endsWith('COUNT=1')) { + return { frequency: 'ONCE' }; + } + const fragments = rule.split(';'); + const frequency = fragments[0].substring(11); + const by = fragments.length > 1 ? fragments[1].split('=')[1].split(',') : null; + return { frequency, by }; +}; + +const formatRule = (rule) => { + const by = rule.by && rule.by.join(','); + switch (rule.frequency) { + case 'DAILY': + return `RRULE:FREQ=${rule.frequency}`; + case 'WEEKLY': + return `RRULE:FREQ=${rule.frequency};BYDAY=${by || 'SU'}`; + case 'MONTHLY': + return `RRULE:FREQ=${rule.frequency};BYMONTHDAY=${by || 1}`; + default: + return 'RRULE:FREQ=DAILY;COUNT=1'; + } +}; + +const updateCalendar = (lines, index, element) => window.btoa(lines.map((e, i) => (i !== index ? e : element)).join('\n')); + +const simpleCalendar = () => window.btoa([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Traccar//NONSGML Traccar//EN', + 'BEGIN:VEVENT', + 'UID:00000000-0000-0000-0000-000000000000', + `DTSTART;${formatCalendarTime(dayjs())}`, + `DTEND;${formatCalendarTime(dayjs().add(1, 'hours'))}`, + 'RRULE:FREQ=DAILY', + 'SUMMARY:Event', + 'END:VEVENT', + 'END:VCALENDAR', +].join('\n')); + +const CalendarPage = () => { + const classes = useSettingsStyles(); + const dispatch = useDispatch(); + const t = useTranslation(); + + const [item, setItem] = useState(); + + const decoded = item && item.data && window.atob(item.data); + + const simple = decoded && decoded.indexOf('//Traccar//') > 0; + + const lines = decoded && decoded.split('\n'); + + const rule = simple && parseRule(lines[7]); + + const handleFiles = (files) => { + if (files.length > 0) { + const reader = new FileReader(); + reader.onload = (event) => { + const { result } = event.target; + setItem({ ...item, data: result.substr(result.indexOf(',') + 1) }); + }; + reader.readAsDataURL(files[0]); + } + }; + + const onItemSaved = useCatch(async () => { + const response = await fetch('/api/calendars'); + if (response.ok) { + dispatch(calendarsActions.refresh(await response.json())); + } else { + throw Error(await response.text()); + } + }); + + const validate = () => item && item.name && item.data; + + return ( + <EditItemView + endpoint="calendars" + item={item} + setItem={setItem} + defaultItem={{ data: simpleCalendar() }} + validate={validate} + onItemSaved={onItemSaved} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedCalendar']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.name || ''} + onChange={(event) => setItem({ ...item, name: event.target.value })} + label={t('sharedName')} + /> + <FormControl> + <InputLabel>{t('sharedType')}</InputLabel> + <Select + label={t('sharedType')} + value={simple ? 'simple' : 'custom'} + onChange={(e) => setItem({ ...item, data: (e.target.value === 'simple' ? simpleCalendar() : null) })} + > + <MenuItem value="simple">{t('calendarSimple')}</MenuItem> + <MenuItem value="custom">{t('reportCustom')}</MenuItem> + </Select> + </FormControl> + {simple ? ( + <> + <TextField + label={t('reportFrom')} + type="datetime-local" + value={dayjs(lines[5].slice(-15)).locale('en').format('YYYY-MM-DDTHH:mm')} + onChange={(e) => { + const time = formatCalendarTime(dayjs(e.target.value, 'YYYY-MM-DDTHH:mm')); + setItem({ ...item, data: updateCalendar(lines, 5, `DTSTART;${time}`) }); + }} + /> + <TextField + label={t('reportTo')} + type="datetime-local" + value={dayjs(lines[6].slice(-15)).locale('en').format('YYYY-MM-DDTHH:mm')} + onChange={(e) => { + const time = formatCalendarTime(dayjs(e.target.value, 'YYYY-MM-DDTHH:mm')); + setItem({ ...item, data: updateCalendar(lines, 6, `DTEND;${time}`) }); + }} + /> + <FormControl> + <InputLabel>{t('calendarRecurrence')}</InputLabel> + <Select + label={t('calendarRecurrence')} + value={rule.frequency} + onChange={(e) => setItem({ ...item, data: updateCalendar(lines, 7, formatRule({ frequency: e.target.value })) })} + > + {['ONCE', 'DAILY', 'WEEKLY', 'MONTHLY'].map((it) => ( + <MenuItem key={it} value={it}>{t(prefixString('calendar', it.toLowerCase()))}</MenuItem> + ))} + </Select> + </FormControl> + {['WEEKLY', 'MONTHLY'].includes(rule.frequency) && ( + <FormControl> + <InputLabel>{t('calendarDays')}</InputLabel> + <Select + multiple + label={t('calendarDays')} + value={rule.by} + onChange={(e) => setItem({ ...item, data: updateCalendar(lines, 7, formatRule({ ...rule, by: e.target.value })) })} + > + {rule.frequency === 'WEEKLY' ? ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].map((it) => ( + <MenuItem key={it} value={it.substring(0, 2).toUpperCase()}>{t(prefixString('calendar', it))}</MenuItem> + )) : Array.from({ length: 31 }, (_, i) => i + 1).map((it) => ( + <MenuItem key={it} value={it}>{it}</MenuItem> + ))} + </Select> + </FormControl> + )} + </> + ) : ( + <DropzoneArea + dropzoneText={t('sharedDropzoneText')} + filesLimit={1} + onChange={handleFiles} + showAlerts={false} + /> + )} + </AccordionDetails> + </Accordion> + <EditAttributesAccordion + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={{}} + /> + </> + )} + </EditItemView> + ); +}; + +export default CalendarPage; diff --git a/src/settings/CalendarsPage.jsx b/src/settings/CalendarsPage.jsx new file mode 100644 index 00000000..de27a451 --- /dev/null +++ b/src/settings/CalendarsPage.jsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { + Table, TableRow, TableCell, TableHead, TableBody, +} from '@mui/material'; +import { useEffectAsync } from '../reactHelper'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import useSettingsStyles from './common/useSettingsStyles'; + +const CalendarsPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/calendars'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedCalendars']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedName')}</TableCell> + <TableCell className={classes.columnAction} /> + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.name}</TableCell> + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions itemId={item.id} editPath="/settings/calendar" endpoint="calendars" setTimestamp={setTimestamp} /> + </TableCell> + </TableRow> + )) : (<TableShimmer columns={2} endAction />)} + </TableBody> + </Table> + <CollectionFab editPath="/settings/calendar" /> + </PageLayout> + ); +}; + +export default CalendarsPage; diff --git a/src/settings/CommandDevicePage.jsx b/src/settings/CommandDevicePage.jsx new file mode 100644 index 00000000..b3144cd0 --- /dev/null +++ b/src/settings/CommandDevicePage.jsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, + Button, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import BaseCommandView from './components/BaseCommandView'; +import SelectField from '../common/components/SelectField'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import { useCatch } from '../reactHelper'; +import { useRestriction } from '../common/util/permissions'; +import useSettingsStyles from './common/useSettingsStyles'; + +const CommandDevicePage = () => { + const navigate = useNavigate(); + const classes = useSettingsStyles(); + const t = useTranslation(); + + const { id } = useParams(); + + const [savedId, setSavedId] = useState(0); + const [item, setItem] = useState({}); + + const limitCommands = useRestriction('limitCommands'); + + const handleSend = useCatch(async () => { + let command; + if (savedId) { + const response = await fetch(`/api/commands/${savedId}`); + if (response.ok) { + command = await response.json(); + } else { + throw Error(await response.text()); + } + } else { + command = item; + } + + command.deviceId = parseInt(id, 10); + + const response = await fetch('/api/commands/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + }); + + if (response.ok) { + navigate(-1); + } else { + throw Error(await response.text()); + } + }); + + const validate = () => savedId || (item && item.type); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'deviceCommand']}> + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + value={savedId} + emptyValue={limitCommands ? null : 0} + emptyTitle={t('sharedNew')} + onChange={(e) => setSavedId(e.target.value)} + endpoint={`/api/commands/send?deviceId=${id}`} + titleGetter={(it) => it.description} + label={t('sharedSavedCommand')} + /> + {!limitCommands && !savedId && ( + <BaseCommandView deviceId={id} item={item} setItem={setItem} /> + )} + </AccordionDetails> + </Accordion> + <div className={classes.buttons}> + <Button + type="button" + color="primary" + variant="outlined" + onClick={() => navigate(-1)} + > + {t('sharedCancel')} + </Button> + <Button + type="button" + color="primary" + variant="contained" + onClick={handleSend} + disabled={!validate()} + > + {t('commandSend')} + </Button> + </div> + </Container> + </PageLayout> + ); +}; + +export default CommandDevicePage; diff --git a/src/settings/CommandGroupPage.jsx b/src/settings/CommandGroupPage.jsx new file mode 100644 index 00000000..e55a235d --- /dev/null +++ b/src/settings/CommandGroupPage.jsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Checkbox, + TextField, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import { useCatch } from '../reactHelper'; +import useSettingsStyles from './common/useSettingsStyles'; + +const CommandDevicePage = () => { + const navigate = useNavigate(); + const classes = useSettingsStyles(); + const t = useTranslation(); + + const { id } = useParams(); + + const textEnabled = useSelector((state) => state.session.server.textEnabled); + + const [item, setItem] = useState({ type: 'custom', attributes: {} }); + + const handleSend = useCatch(async () => { + const query = new URLSearchParams({ groupId: id }); + const response = await fetch(`/api/commands/send?${query.toString()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item), + }); + + if (response.ok) { + navigate(-1); + } else { + throw Error(await response.text()); + } + }); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'deviceCommand']}> + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <FormControl fullWidth> + <InputLabel>{t('sharedType')}</InputLabel> + <Select label={t('sharedType')} value="custom" disabled> + <MenuItem value="custom">{t('commandCustom')}</MenuItem> + </Select> + </FormControl> + <TextField + value={item.attributes.data} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, data: e.target.value } })} + label={t('commandData')} + /> + {textEnabled && ( + <FormControlLabel + control={<Checkbox checked={item.textChannel} onChange={(event) => setItem({ ...item, textChannel: event.target.checked })} />} + label={t('commandSendSms')} + /> + )} + </AccordionDetails> + </Accordion> + <div className={classes.buttons}> + <Button + type="button" + color="primary" + variant="outlined" + onClick={() => navigate(-1)} + > + {t('sharedCancel')} + </Button> + <Button + type="button" + color="primary" + variant="contained" + onClick={handleSend} + disabled={!item.attributes.data} + > + {t('commandSend')} + </Button> + </div> + </Container> + </PageLayout> + ); +}; + +export default CommandDevicePage; diff --git a/src/settings/CommandPage.jsx b/src/settings/CommandPage.jsx new file mode 100644 index 00000000..e65ecd76 --- /dev/null +++ b/src/settings/CommandPage.jsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { + Accordion, AccordionSummary, AccordionDetails, Typography, TextField, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditItemView from './components/EditItemView'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import BaseCommandView from './components/BaseCommandView'; +import SettingsMenu from './components/SettingsMenu'; +import useSettingsStyles from './common/useSettingsStyles'; + +const CommandPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [item, setItem] = useState(); + + const validate = () => item && item.type; + + return ( + <EditItemView + endpoint="commands" + item={item} + setItem={setItem} + validate={validate} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedSavedCommand']} + > + {item && ( + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.description || ''} + onChange={(event) => setItem({ ...item, description: event.target.value })} + label={t('sharedDescription')} + /> + <BaseCommandView item={item} setItem={setItem} /> + </AccordionDetails> + </Accordion> + )} + </EditItemView> + ); +}; + +export default CommandPage; diff --git a/src/settings/CommandsPage.jsx b/src/settings/CommandsPage.jsx new file mode 100644 index 00000000..1b893831 --- /dev/null +++ b/src/settings/CommandsPage.jsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { + Table, TableRow, TableCell, TableHead, TableBody, +} from '@mui/material'; +import { useEffectAsync } from '../reactHelper'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import { formatBoolean } from '../common/util/formatter'; +import { prefixString } from '../common/util/stringUtils'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import { useRestriction } from '../common/util/permissions'; +import useSettingsStyles from './common/useSettingsStyles'; + +const CommandsPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + const limitCommands = useRestriction('limitCommands'); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/commands'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedSavedCommands']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedDescription')}</TableCell> + <TableCell>{t('sharedType')}</TableCell> + <TableCell>{t('commandSendSms')}</TableCell> + {!limitCommands && <TableCell className={classes.columnAction} />} + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.description}</TableCell> + <TableCell>{t(prefixString('command', item.type))}</TableCell> + <TableCell>{formatBoolean(item.textChannel, t)}</TableCell> + {!limitCommands && ( + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions itemId={item.id} editPath="/settings/command" endpoint="commands" setTimestamp={setTimestamp} /> + </TableCell> + )} + </TableRow> + )) : (<TableShimmer columns={limitCommands ? 3 : 4} endAction />)} + </TableBody> + </Table> + <CollectionFab editPath="/settings/command" disabled={limitCommands} /> + </PageLayout> + ); +}; + +export default CommandsPage; diff --git a/src/settings/ComputedAttributePage.jsx b/src/settings/ComputedAttributePage.jsx new file mode 100644 index 00000000..1b19033c --- /dev/null +++ b/src/settings/ComputedAttributePage.jsx @@ -0,0 +1,177 @@ +import React, { useState } from 'react'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + createFilterOptions, + Autocomplete, + Button, + Snackbar, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditItemView from './components/EditItemView'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import usePositionAttributes from '../common/attributes/usePositionAttributes'; +import SettingsMenu from './components/SettingsMenu'; +import SelectField from '../common/components/SelectField'; +import { useCatch } from '../reactHelper'; +import { snackBarDurationLongMs } from '../common/util/duration'; +import useSettingsStyles from './common/useSettingsStyles'; + +const allowedProperties = ['valid', 'latitude', 'longitude', 'altitude', 'speed', 'course', 'address', 'accuracy']; + +const ComputedAttributePage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const positionAttributes = usePositionAttributes(t); + + const [item, setItem] = useState(); + const [deviceId, setDeviceId] = useState(); + const [result, setResult] = useState(); + + const options = Object.entries(positionAttributes).filter(([key, value]) => !value.property || allowedProperties.includes(key)).map(([key, value]) => ({ + key, + name: value.name, + type: value.type, + })); + + const filter = createFilterOptions({ + stringify: (option) => option.name, + }); + + const testAttribute = useCatch(async () => { + const query = new URLSearchParams({ deviceId }); + const url = `/api/attributes/computed/test?${query.toString()}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item), + }); + if (response.ok) { + setResult(await response.text()); + } else { + throw Error(await response.text()); + } + }); + + const validate = () => item && item.description && item.expression; + + return ( + <EditItemView + endpoint="attributes/computed" + item={item} + setItem={setItem} + validate={validate} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedComputedAttribute']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.description || ''} + onChange={(e) => setItem({ ...item, description: e.target.value })} + label={t('sharedDescription')} + /> + <Autocomplete + value={options.find((option) => option.key === item.attribute) || item.attribute} + onChange={(_, option) => { + const attribute = option ? option.key || option : null; + if (option && option.type) { + setItem({ ...item, attribute, type: option.type }); + } else { + setItem({ ...item, attribute }); + } + }} + filterOptions={(options, params) => { + const filtered = filter(options, params); + if (params.inputValue) { + filtered.push({ + key: params.inputValue, + name: params.inputValue, + }); + } + return filtered; + }} + options={options} + getOptionLabel={(option) => option.name || option} + renderOption={(props, option) => ( + <li {...props}> + {option.name} + </li> + )} + renderInput={(params) => ( + <TextField {...params} label={t('sharedAttribute')} /> + )} + freeSolo + /> + <TextField + value={item.expression || ''} + onChange={(e) => setItem({ ...item, expression: e.target.value })} + label={t('sharedExpression')} + multiline + rows={4} + /> + <FormControl disabled={item.attribute in positionAttributes}> + <InputLabel>{t('sharedType')}</InputLabel> + <Select + label={t('sharedType')} + value={item.type || ''} + onChange={(e) => setItem({ ...item, type: e.target.value })} + > + <MenuItem value="string">{t('sharedTypeString')}</MenuItem> + <MenuItem value="number">{t('sharedTypeNumber')}</MenuItem> + <MenuItem value="boolean">{t('sharedTypeBoolean')}</MenuItem> + </Select> + </FormControl> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedTest')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + value={deviceId} + onChange={(e) => setDeviceId(Number(e.target.value))} + endpoint="/api/devices" + label={t('sharedDevice')} + /> + <Button + variant="outlined" + color="primary" + onClick={testAttribute} + disabled={!deviceId} + > + {t('sharedTestExpression')} + </Button> + <Snackbar + open={!!result} + onClose={() => setResult(null)} + autoHideDuration={snackBarDurationLongMs} + message={result} + /> + </AccordionDetails> + </Accordion> + </> + )} + </EditItemView> + ); +}; + +export default ComputedAttributePage; diff --git a/src/settings/ComputedAttributesPage.jsx b/src/settings/ComputedAttributesPage.jsx new file mode 100644 index 00000000..6d098547 --- /dev/null +++ b/src/settings/ComputedAttributesPage.jsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { + Table, TableRow, TableCell, TableHead, TableBody, +} from '@mui/material'; +import { useEffectAsync } from '../reactHelper'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import { useAdministrator } from '../common/util/permissions'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import useSettingsStyles from './common/useSettingsStyles'; + +const ComputedAttributesPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + const administrator = useAdministrator(); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/attributes/computed'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedComputedAttributes']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedDescription')}</TableCell> + <TableCell>{t('sharedAttribute')}</TableCell> + <TableCell>{t('sharedExpression')}</TableCell> + <TableCell>{t('sharedType')}</TableCell> + {administrator && <TableCell className={classes.columnAction} />} + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.description}</TableCell> + <TableCell>{item.attribute}</TableCell> + <TableCell>{item.expression}</TableCell> + <TableCell>{item.type}</TableCell> + {administrator && ( + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions itemId={item.id} editPath="/settings/attribute" endpoint="attributes/computed" setTimestamp={setTimestamp} /> + </TableCell> + )} + </TableRow> + )) : (<TableShimmer columns={administrator ? 5 : 4} endAction={administrator} />)} + </TableBody> + </Table> + <CollectionFab editPath="/settings/attribute" disabled={!administrator} /> + </PageLayout> + ); +}; + +export default ComputedAttributesPage; diff --git a/src/settings/DeviceConnectionsPage.jsx b/src/settings/DeviceConnectionsPage.jsx new file mode 100644 index 00000000..c711d719 --- /dev/null +++ b/src/settings/DeviceConnectionsPage.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import LinkField from '../common/components/LinkField'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import SettingsMenu from './components/SettingsMenu'; +import { formatNotificationTitle } from '../common/util/formatter'; +import PageLayout from '../common/components/PageLayout'; +import useFeatures from '../common/util/useFeatures'; +import useSettingsStyles from './common/useSettingsStyles'; + +const DeviceConnectionsPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const { id } = useParams(); + + const features = useFeatures(); + + return ( + <PageLayout + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedDevice', 'sharedConnections']} + > + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedConnections')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <LinkField + endpointAll="/api/geofences" + endpointLinked={`/api/geofences?deviceId=${id}`} + baseId={id} + keyBase="deviceId" + keyLink="geofenceId" + label={t('sharedGeofences')} + /> + <LinkField + endpointAll="/api/notifications" + endpointLinked={`/api/notifications?deviceId=${id}`} + baseId={id} + keyBase="deviceId" + keyLink="notificationId" + titleGetter={(it) => formatNotificationTitle(t, it)} + label={t('sharedNotifications')} + /> + {!features.disableDrivers && ( + <LinkField + endpointAll="/api/drivers" + endpointLinked={`/api/drivers?deviceId=${id}`} + baseId={id} + keyBase="deviceId" + keyLink="driverId" + titleGetter={(it) => `${it.name} (${it.uniqueId})`} + label={t('sharedDrivers')} + /> + )} + {!features.disableComputedAttributes && ( + <LinkField + endpointAll="/api/attributes/computed" + endpointLinked={`/api/attributes/computed?deviceId=${id}`} + baseId={id} + keyBase="deviceId" + keyLink="attributeId" + titleGetter={(it) => it.description} + label={t('sharedComputedAttributes')} + /> + )} + {!features.disableSavedCommands && ( + <LinkField + endpointAll="/api/commands" + endpointLinked={`/api/commands?deviceId=${id}`} + baseId={id} + keyBase="deviceId" + keyLink="commandId" + titleGetter={(it) => it.description} + label={t('sharedSavedCommands')} + /> + )} + {!features.disableMaintenance && ( + <LinkField + endpointAll="/api/maintenance" + endpointLinked={`/api/maintenance?deviceId=${id}`} + baseId={id} + keyBase="deviceId" + keyLink="maintenanceId" + label={t('sharedMaintenance')} + /> + )} + </AccordionDetails> + </Accordion> + </Container> + </PageLayout> + ); +}; + +export default DeviceConnectionsPage; diff --git a/src/settings/DevicePage.jsx b/src/settings/DevicePage.jsx new file mode 100644 index 00000000..8933a210 --- /dev/null +++ b/src/settings/DevicePage.jsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + FormControlLabel, + Checkbox, + TextField, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { DropzoneArea } from 'react-mui-dropzone'; +import EditItemView from './components/EditItemView'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import SelectField from '../common/components/SelectField'; +import deviceCategories from '../common/util/deviceCategories'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import useDeviceAttributes from '../common/attributes/useDeviceAttributes'; +import { useAdministrator } from '../common/util/permissions'; +import SettingsMenu from './components/SettingsMenu'; +import useCommonDeviceAttributes from '../common/attributes/useCommonDeviceAttributes'; +import { useCatch } from '../reactHelper'; +import useQuery from '../common/util/useQuery'; +import useSettingsStyles from './common/useSettingsStyles'; + +const DevicePage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const admin = useAdministrator(); + + const commonDeviceAttributes = useCommonDeviceAttributes(t); + const deviceAttributes = useDeviceAttributes(t); + + const query = useQuery(); + const uniqueId = query.get('uniqueId'); + + const [item, setItem] = useState(uniqueId ? { uniqueId } : null); + + const handleFiles = useCatch(async (files) => { + if (files.length > 0) { + const response = await fetch(`/api/devices/${item.id}/image`, { + method: 'POST', + body: files[0], + }); + if (response.ok) { + setItem({ ...item, attributes: { ...item.attributes, deviceImage: await response.text() } }); + } else { + throw Error(await response.text()); + } + } + }); + + const validate = () => item && item.name && item.uniqueId; + + return ( + <EditItemView + endpoint="devices" + item={item} + setItem={setItem} + validate={validate} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedDevice']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.name || ''} + onChange={(event) => setItem({ ...item, name: event.target.value })} + label={t('sharedName')} + /> + <TextField + value={item.uniqueId || ''} + onChange={(event) => setItem({ ...item, uniqueId: event.target.value })} + label={t('deviceIdentifier')} + helperText={t('deviceIdentifierHelp')} + disabled={Boolean(uniqueId)} + /> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedExtra')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + value={item.groupId} + onChange={(event) => setItem({ ...item, groupId: Number(event.target.value) })} + endpoint="/api/groups" + label={t('groupParent')} + /> + <TextField + value={item.phone || ''} + onChange={(event) => setItem({ ...item, phone: event.target.value })} + label={t('sharedPhone')} + /> + <TextField + value={item.model || ''} + onChange={(event) => setItem({ ...item, model: event.target.value })} + label={t('deviceModel')} + /> + <TextField + value={item.contact || ''} + onChange={(event) => setItem({ ...item, contact: event.target.value })} + label={t('deviceContact')} + /> + <SelectField + value={item.category || 'default'} + onChange={(event) => setItem({ ...item, category: event.target.value })} + data={deviceCategories.map((category) => ({ + id: category, + name: t(`category${category.replace(/^\w/, (c) => c.toUpperCase())}`), + }))} + label={t('deviceCategory')} + /> + <SelectField + value={item.calendarId} + onChange={(event) => setItem({ ...item, calendarId: Number(event.target.value) })} + endpoint="/api/calendars" + label={t('sharedCalendar')} + /> + <TextField + label={t('userExpirationTime')} + type="date" + value={(item.expirationTime && dayjs(item.expirationTime).locale('en').format('YYYY-MM-DD')) || '2099-01-01'} + onChange={(e) => setItem({ ...item, expirationTime: dayjs(e.target.value, 'YYYY-MM-DD').locale('en').format() })} + disabled={!admin} + /> + <FormControlLabel + control={<Checkbox checked={item.disabled} onChange={(event) => setItem({ ...item, disabled: event.target.checked })} />} + label={t('sharedDisabled')} + disabled={!admin} + /> + </AccordionDetails> + </Accordion> + {item.id && ( + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('attributeDeviceImage')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <DropzoneArea + dropzoneText={t('sharedDropzoneText')} + acceptedFiles={['image/*']} + filesLimit={1} + onChange={handleFiles} + showAlerts={false} + maxFileSize={500000} + /> + </AccordionDetails> + </Accordion> + )} + <EditAttributesAccordion + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={{ ...commonDeviceAttributes, ...deviceAttributes }} + /> + </> + )} + </EditItemView> + ); +}; + +export default DevicePage; diff --git a/src/settings/DevicesPage.jsx b/src/settings/DevicesPage.jsx new file mode 100644 index 00000000..c0da0ba7 --- /dev/null +++ b/src/settings/DevicesPage.jsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + Table, TableRow, TableCell, TableHead, TableBody, Button, TableFooter, +} from '@mui/material'; +import LinkIcon from '@mui/icons-material/Link'; +import { useEffectAsync } from '../reactHelper'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import { usePreference } from '../common/util/preferences'; +import { formatTime } from '../common/util/formatter'; +import { useDeviceReadonly } from '../common/util/permissions'; +import useSettingsStyles from './common/useSettingsStyles'; + +const DevicesPage = () => { + const classes = useSettingsStyles(); + const navigate = useNavigate(); + const t = useTranslation(); + + const groups = useSelector((state) => state.groups.items); + + const hours12 = usePreference('twelveHourFormat'); + + const deviceReadonly = useDeviceReadonly(); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/devices'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + const handleExport = () => { + window.location.assign('/api/reports/devices/xlsx'); + }; + + const actionConnections = { + key: 'connections', + title: t('sharedConnections'), + icon: <LinkIcon fontSize="small" />, + handler: (deviceId) => navigate(`/settings/device/${deviceId}/connections`), + }; + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'deviceTitle']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedName')}</TableCell> + <TableCell>{t('deviceIdentifier')}</TableCell> + <TableCell>{t('groupParent')}</TableCell> + <TableCell>{t('sharedPhone')}</TableCell> + <TableCell>{t('deviceModel')}</TableCell> + <TableCell>{t('deviceContact')}</TableCell> + <TableCell>{t('userExpirationTime')}</TableCell> + <TableCell className={classes.columnAction} /> + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.name}</TableCell> + <TableCell>{item.uniqueId}</TableCell> + <TableCell>{item.groupId ? groups[item.groupId]?.name : null}</TableCell> + <TableCell>{item.phone}</TableCell> + <TableCell>{item.model}</TableCell> + <TableCell>{item.contact}</TableCell> + <TableCell>{formatTime(item.expirationTime, 'date', hours12)}</TableCell> + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions + itemId={item.id} + editPath="/settings/device" + endpoint="devices" + setTimestamp={setTimestamp} + customActions={[actionConnections]} + readonly={deviceReadonly} + /> + </TableCell> + </TableRow> + )) : (<TableShimmer columns={7} endAction />)} + </TableBody> + <TableFooter> + <TableRow> + <TableCell colSpan={8} align="right"> + <Button onClick={handleExport} variant="text">{t('reportExport')}</Button> + </TableCell> + </TableRow> + </TableFooter> + </Table> + <CollectionFab editPath="/settings/device" /> + </PageLayout> + ); +}; + +export default DevicesPage; diff --git a/src/settings/DriverPage.jsx b/src/settings/DriverPage.jsx new file mode 100644 index 00000000..5f70a44a --- /dev/null +++ b/src/settings/DriverPage.jsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import TextField from '@mui/material/TextField'; +import { + Accordion, AccordionSummary, AccordionDetails, Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditItemView from './components/EditItemView'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import SettingsMenu from './components/SettingsMenu'; +import useSettingsStyles from './common/useSettingsStyles'; + +const DriverPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [item, setItem] = useState(); + + const validate = () => item && item.name && item.uniqueId; + + return ( + <EditItemView + endpoint="drivers" + item={item} + setItem={setItem} + validate={validate} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedDriver']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.name || ''} + onChange={(event) => setItem({ ...item, name: event.target.value })} + label={t('sharedName')} + /> + <TextField + value={item.uniqueId || ''} + onChange={(event) => setItem({ ...item, uniqueId: event.target.value })} + label={t('deviceIdentifier')} + /> + </AccordionDetails> + </Accordion> + <EditAttributesAccordion + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={{}} + /> + </> + )} + </EditItemView> + ); +}; + +export default DriverPage; diff --git a/src/settings/DriversPage.jsx b/src/settings/DriversPage.jsx new file mode 100644 index 00000000..72834860 --- /dev/null +++ b/src/settings/DriversPage.jsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { + Table, TableRow, TableCell, TableHead, TableBody, +} from '@mui/material'; +import { useEffectAsync } from '../reactHelper'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import useSettingsStyles from './common/useSettingsStyles'; + +const DriversPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/drivers'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedDrivers']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedName')}</TableCell> + <TableCell>{t('deviceIdentifier')}</TableCell> + <TableCell className={classes.columnAction} /> + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.name}</TableCell> + <TableCell>{item.uniqueId}</TableCell> + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions itemId={item.id} editPath="/settings/driver" endpoint="drivers" setTimestamp={setTimestamp} /> + </TableCell> + </TableRow> + )) : (<TableShimmer columns={3} endAction />)} + </TableBody> + </Table> + <CollectionFab editPath="/settings/driver" /> + </PageLayout> + ); +}; + +export default DriversPage; diff --git a/src/settings/GeofencePage.jsx b/src/settings/GeofencePage.jsx new file mode 100644 index 00000000..c3c96ef8 --- /dev/null +++ b/src/settings/GeofencePage.jsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + Accordion, AccordionSummary, AccordionDetails, Typography, TextField, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditItemView from './components/EditItemView'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import useGeofenceAttributes from '../common/attributes/useGeofenceAttributes'; +import SettingsMenu from './components/SettingsMenu'; +import SelectField from '../common/components/SelectField'; +import { geofencesActions } from '../store'; +import useSettingsStyles from './common/useSettingsStyles'; + +const GeofencePage = () => { + const classes = useSettingsStyles(); + const dispatch = useDispatch(); + const t = useTranslation(); + + const geofenceAttributes = useGeofenceAttributes(t); + + const [item, setItem] = useState(); + + const onItemSaved = (result) => { + dispatch(geofencesActions.update([result])); + }; + + const validate = () => item && item.name; + + return ( + <EditItemView + endpoint="geofences" + item={item} + setItem={setItem} + validate={validate} + onItemSaved={onItemSaved} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedGeofence']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.name || ''} + onChange={(event) => setItem({ ...item, name: event.target.value })} + label={t('sharedName')} + /> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedExtra')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.description || ''} + onChange={(event) => setItem({ ...item, description: event.target.value })} + label={t('sharedDescription')} + /> + <SelectField + value={item.calendarId} + onChange={(event) => setItem({ ...item, calendarId: Number(event.target.value) })} + endpoint="/api/calendars" + label={t('sharedCalendar')} + /> + </AccordionDetails> + </Accordion> + <EditAttributesAccordion + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={geofenceAttributes} + /> + </> + )} + </EditItemView> + ); +}; + +export default GeofencePage; diff --git a/src/settings/GroupConnectionsPage.jsx b/src/settings/GroupConnectionsPage.jsx new file mode 100644 index 00000000..980bd9da --- /dev/null +++ b/src/settings/GroupConnectionsPage.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import LinkField from '../common/components/LinkField'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import SettingsMenu from './components/SettingsMenu'; +import { formatNotificationTitle } from '../common/util/formatter'; +import PageLayout from '../common/components/PageLayout'; +import useFeatures from '../common/util/useFeatures'; +import useSettingsStyles from './common/useSettingsStyles'; + +const GroupConnectionsPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const { id } = useParams(); + + const features = useFeatures(); + + return ( + <PageLayout + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'groupDialog', 'sharedConnections']} + > + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedConnections')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <LinkField + endpointAll="/api/geofences" + endpointLinked={`/api/geofences?groupId=${id}`} + baseId={id} + keyBase="groupId" + keyLink="geofenceId" + label={t('sharedGeofences')} + /> + <LinkField + endpointAll="/api/notifications" + endpointLinked={`/api/notifications?groupId=${id}`} + baseId={id} + keyBase="groupId" + keyLink="notificationId" + titleGetter={(it) => formatNotificationTitle(t, it)} + label={t('sharedNotifications')} + /> + {!features.disableDrivers && ( + <LinkField + endpointAll="/api/drivers" + endpointLinked={`/api/drivers?groupId=${id}`} + baseId={id} + keyBase="groupId" + keyLink="driverId" + titleGetter={(it) => `${it.name} (${it.uniqueId})`} + label={t('sharedDrivers')} + /> + )} + {!features.disableComputedAttributes && ( + <LinkField + endpointAll="/api/attributes/computed" + endpointLinked={`/api/attributes/computed?groupId=${id}`} + baseId={id} + keyBase="groupId" + keyLink="attributeId" + titleGetter={(it) => it.description} + label={t('sharedComputedAttributes')} + /> + )} + {!features.disableSavedCommands && ( + <LinkField + endpointAll="/api/commands" + endpointLinked={`/api/commands?groupId=${id}`} + baseId={id} + keyBase="groupId" + keyLink="commandId" + titleGetter={(it) => it.description} + label={t('sharedSavedCommands')} + /> + )} + {!features.disableMaintenance && ( + <LinkField + endpointAll="/api/maintenance" + endpointLinked={`/api/maintenance?groupId=${id}`} + baseId={id} + keyBase="groupId" + keyLink="maintenanceId" + label={t('sharedMaintenance')} + /> + )} + </AccordionDetails> + </Accordion> + </Container> + </PageLayout> + ); +}; + +export default GroupConnectionsPage; diff --git a/src/settings/GroupPage.jsx b/src/settings/GroupPage.jsx new file mode 100644 index 00000000..ba1cbc76 --- /dev/null +++ b/src/settings/GroupPage.jsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import TextField from '@mui/material/TextField'; + +import { + Accordion, AccordionSummary, AccordionDetails, Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditItemView from './components/EditItemView'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import SelectField from '../common/components/SelectField'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import SettingsMenu from './components/SettingsMenu'; +import useCommonDeviceAttributes from '../common/attributes/useCommonDeviceAttributes'; +import useGroupAttributes from '../common/attributes/useGroupAttributes'; +import { useCatch } from '../reactHelper'; +import { groupsActions } from '../store'; +import useSettingsStyles from './common/useSettingsStyles'; + +const GroupPage = () => { + const classes = useSettingsStyles(); + const dispatch = useDispatch(); + const t = useTranslation(); + + const commonDeviceAttributes = useCommonDeviceAttributes(t); + const groupAttributes = useGroupAttributes(t); + + const [item, setItem] = useState(); + + const onItemSaved = useCatch(async () => { + const response = await fetch('/api/groups'); + if (response.ok) { + dispatch(groupsActions.refresh(await response.json())); + } else { + throw Error(await response.text()); + } + }); + + const validate = () => item && item.name; + + return ( + <EditItemView + endpoint="groups" + item={item} + setItem={setItem} + validate={validate} + onItemSaved={onItemSaved} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'groupDialog']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.name || ''} + onChange={(event) => setItem({ ...item, name: event.target.value })} + label={t('sharedName')} + /> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedExtra')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + value={item.groupId} + onChange={(event) => setItem({ ...item, groupId: Number(event.target.value) })} + endpoint="/api/groups" + label={t('groupParent')} + /> + </AccordionDetails> + </Accordion> + <EditAttributesAccordion + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={{ ...commonDeviceAttributes, ...groupAttributes }} + /> + </> + )} + </EditItemView> + ); +}; + +export default GroupPage; diff --git a/src/settings/GroupsPage.jsx b/src/settings/GroupsPage.jsx new file mode 100644 index 00000000..baba7f72 --- /dev/null +++ b/src/settings/GroupsPage.jsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Table, TableRow, TableCell, TableHead, TableBody, +} from '@mui/material'; +import LinkIcon from '@mui/icons-material/Link'; +import PublishIcon from '@mui/icons-material/Publish'; +import { useEffectAsync } from '../reactHelper'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import { useRestriction } from '../common/util/permissions'; +import useSettingsStyles from './common/useSettingsStyles'; + +const GroupsPage = () => { + const classes = useSettingsStyles(); + const navigate = useNavigate(); + const t = useTranslation(); + + const limitCommands = useRestriction('limitCommands'); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/groups'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + const actionCommand = { + key: 'command', + title: t('deviceCommand'), + icon: <PublishIcon fontSize="small" />, + handler: (groupId) => navigate(`/settings/group/${groupId}/command`), + }; + + const actionConnections = { + key: 'connections', + title: t('sharedConnections'), + icon: <LinkIcon fontSize="small" />, + handler: (groupId) => navigate(`/settings/group/${groupId}/connections`), + }; + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'settingsGroups']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedName')}</TableCell> + <TableCell className={classes.columnAction} /> + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.name}</TableCell> + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions + itemId={item.id} + editPath="/settings/group" + endpoint="groups" + setTimestamp={setTimestamp} + customActions={limitCommands ? [actionConnections] : [actionConnections, actionCommand]} + /> + </TableCell> + </TableRow> + )) : (<TableShimmer columns={2} endAction />)} + </TableBody> + </Table> + <CollectionFab editPath="/settings/group" /> + </PageLayout> + ); +}; + +export default GroupsPage; diff --git a/src/settings/MaintenancePage.jsx b/src/settings/MaintenancePage.jsx new file mode 100644 index 00000000..491a0d3b --- /dev/null +++ b/src/settings/MaintenancePage.jsx @@ -0,0 +1,174 @@ +import React, { useEffect, useState } from 'react'; +import dayjs from 'dayjs'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + TextField, + FormControl, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { prefixString } from '../common/util/stringUtils'; +import EditItemView from './components/EditItemView'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import { useAttributePreference } from '../common/util/preferences'; +import { + speedFromKnots, speedToKnots, distanceFromMeters, distanceToMeters, +} from '../common/util/converter'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import usePositionAttributes from '../common/attributes/usePositionAttributes'; +import SettingsMenu from './components/SettingsMenu'; +import useSettingsStyles from './common/useSettingsStyles'; + +const MaintenancePage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const positionAttributes = usePositionAttributes(t); + + const [item, setItem] = useState(); + const [labels, setLabels] = useState({ start: '', period: '' }); + + const speedUnit = useAttributePreference('speedUnit', 'kn'); + const distanceUnit = useAttributePreference('distanceUnit', 'km'); + + const convertToList = (attributes) => { + const otherList = []; + Object.keys(attributes).forEach((key) => { + const value = attributes[key]; + if (value.type === 'number' || key.endsWith('Time')) { + otherList.push({ key, name: value.name, type: value.type }); + } + }); + return otherList; + }; + + useEffect(() => { + const attribute = positionAttributes[item?.type]; + if (item?.type?.endsWith('Time')) { + setLabels({ ...labels, start: null, period: t('sharedDays') }); + } else if (attribute && attribute.dataType) { + switch (attribute.dataType) { + case 'distance': + setLabels({ ...labels, start: t(prefixString('shared', distanceUnit)), period: t(prefixString('shared', distanceUnit)) }); + break; + case 'speed': + setLabels({ ...labels, start: t(prefixString('shared', speedUnit)), period: t(prefixString('shared', speedUnit)) }); + break; + default: + setLabels({ ...labels, start: null, period: null }); + break; + } + } else { + setLabels({ ...labels, start: null, period: null }); + } + }, [item?.type]); + + const rawToValue = (start, value) => { + const attribute = positionAttributes[item.type]; + if (item.type?.endsWith('Time')) { + if (start) { + return dayjs(value).locale('en').format('YYYY-MM-DD'); + } + return value / 86400000; + } + if (attribute && attribute.dataType) { + switch (attribute.dataType) { + case 'speed': + return speedFromKnots(value, speedUnit); + case 'distance': + return distanceFromMeters(value, distanceUnit); + default: + return value; + } + } + return value; + }; + + const valueToRaw = (start, value) => { + const attribute = positionAttributes[item.type]; + if (item.type?.endsWith('Time')) { + if (start) { + return dayjs(value, 'YYYY-MM-DD').valueOf(); + } + return value * 86400000; + } if (attribute && attribute.dataType) { + switch (attribute.dataType) { + case 'speed': + return speedToKnots(value, speedUnit); + case 'distance': + return distanceToMeters(value, distanceUnit); + default: + return value; + } + } + return value; + }; + + const validate = () => item && item.name && item.type && item.start && item.period; + + return ( + <EditItemView + endpoint="maintenance" + item={item} + setItem={setItem} + validate={validate} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedMaintenance']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.name || ''} + onChange={(e) => setItem({ ...item, name: e.target.value })} + label={t('sharedName')} + /> + <FormControl> + <InputLabel>{t('sharedType')}</InputLabel> + <Select + label={t('sharedType')} + value={item.type || ''} + onChange={(e) => setItem({ ...item, type: e.target.value, start: 0, period: 0 })} + > + {convertToList(positionAttributes).map(({ key, name }) => ( + <MenuItem key={key} value={key}>{name}</MenuItem> + ))} + </Select> + </FormControl> + <TextField + type={item.type?.endsWith('Time') ? 'date' : 'number'} + value={rawToValue(true, item.start) || ''} + onChange={(e) => setItem({ ...item, start: valueToRaw(true, e.target.value) })} + label={labels.start ? `${t('maintenanceStart')} (${labels.start})` : t('maintenanceStart')} + /> + <TextField + type="number" + value={rawToValue(false, item.period) || ''} + onChange={(e) => setItem({ ...item, period: valueToRaw(false, e.target.value) })} + label={labels.period ? `${t('maintenancePeriod')} (${labels.period})` : t('maintenancePeriod')} + /> + </AccordionDetails> + </Accordion> + <EditAttributesAccordion + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={{}} + /> + </> + )} + </EditItemView> + ); +}; + +export default MaintenancePage; diff --git a/src/settings/MaintenancesPage.jsx b/src/settings/MaintenancesPage.jsx new file mode 100644 index 00000000..9241eb3e --- /dev/null +++ b/src/settings/MaintenancesPage.jsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; +import { + Table, TableRow, TableCell, TableHead, TableBody, +} from '@mui/material'; +import { useEffectAsync } from '../reactHelper'; +import usePositionAttributes from '../common/attributes/usePositionAttributes'; +import { formatDistance, formatSpeed } from '../common/util/formatter'; +import { useAttributePreference } from '../common/util/preferences'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import useSettingsStyles from './common/useSettingsStyles'; + +const MaintenacesPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const positionAttributes = usePositionAttributes(t); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + const speedUnit = useAttributePreference('speedUnit'); + const distanceUnit = useAttributePreference('distanceUnit'); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/maintenance'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + const convertAttribute = (key, start, value) => { + const attribute = positionAttributes[key]; + if (key.endsWith('Time')) { + if (start) { + return dayjs(value).locale('en').format('YYYY-MM-DD'); + } + return `${value / 86400000} ${t('sharedDays')}`; + } + if (attribute && attribute.dataType) { + switch (attribute.dataType) { + case 'speed': + return formatSpeed(value, speedUnit, t); + case 'distance': + return formatDistance(value, distanceUnit, t); + default: + return value; + } + } + + return value; + }; + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedMaintenance']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedName')}</TableCell> + <TableCell>{t('sharedType')}</TableCell> + <TableCell>{t('maintenanceStart')}</TableCell> + <TableCell>{t('maintenancePeriod')}</TableCell> + <TableCell className={classes.columnAction} /> + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.name}</TableCell> + <TableCell>{item.type}</TableCell> + <TableCell>{convertAttribute(item.type, true, item.start)}</TableCell> + <TableCell>{convertAttribute(item.type, false, item.period)}</TableCell> + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions itemId={item.id} editPath="/settings/maintenance" endpoint="maintenance" setTimestamp={setTimestamp} /> + </TableCell> + </TableRow> + )) : (<TableShimmer columns={5} endAction />)} + </TableBody> + </Table> + <CollectionFab editPath="/settings/maintenance" /> + </PageLayout> + ); +}; + +export default MaintenacesPage; diff --git a/src/settings/NotificationPage.jsx b/src/settings/NotificationPage.jsx new file mode 100644 index 00000000..63aa9b95 --- /dev/null +++ b/src/settings/NotificationPage.jsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; + +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + FormControlLabel, + Checkbox, + FormGroup, + Button, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useTranslation, useTranslationKeys } from '../common/components/LocalizationProvider'; +import EditItemView from './components/EditItemView'; +import { prefixString, unprefixString } from '../common/util/stringUtils'; +import SelectField from '../common/components/SelectField'; +import SettingsMenu from './components/SettingsMenu'; +import { useCatch } from '../reactHelper'; +import useSettingsStyles from './common/useSettingsStyles'; + +const NotificationPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [item, setItem] = useState(); + + const alarms = useTranslationKeys((it) => it.startsWith('alarm')).map((it) => ({ + key: unprefixString('alarm', it), + name: t(it), + })); + + const testNotificators = useCatch(async () => { + await Promise.all(item.notificators.split(/[, ]+/).map(async (notificator) => { + const response = await fetch(`/api/notifications/test/${notificator}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item), + }); + if (!response.ok) { + throw Error(await response.text()); + } + })); + }); + + const validate = () => item && item.type && item.notificators && (!item.notificators?.includes('command') || item.commandId); + + return ( + <EditItemView + endpoint="notifications" + item={item} + setItem={setItem} + validate={validate} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'sharedNotification']} + > + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + value={item.type} + onChange={(e) => setItem({ ...item, type: e.target.value })} + endpoint="/api/notifications/types" + keyGetter={(it) => it.type} + titleGetter={(it) => t(prefixString('event', it.type))} + label={t('sharedType')} + /> + {item.type === 'alarm' && ( + <SelectField + multiple + value={item.attributes && item.attributes.alarms ? item.attributes.alarms.split(/[, ]+/) : []} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, alarms: e.target.value.join() } })} + data={alarms} + keyGetter={(it) => it.key} + label={t('sharedAlarms')} + /> + )} + <SelectField + multiple + value={item.notificators ? item.notificators.split(/[, ]+/) : []} + onChange={(e) => setItem({ ...item, notificators: e.target.value.join() })} + endpoint="/api/notifications/notificators" + keyGetter={(it) => it.type} + titleGetter={(it) => t(prefixString('notificator', it.type))} + label={t('notificationNotificators')} + /> + {item.notificators?.includes('command') && ( + <SelectField + value={item.commandId} + onChange={(event) => setItem({ ...item, commandId: Number(event.target.value) })} + endpoint="/api/commands" + titleGetter={(it) => it.description} + label={t('sharedSavedCommand')} + /> + )} + <Button + variant="outlined" + color="primary" + onClick={testNotificators} + disabled={!item.notificators} + > + {t('sharedTestNotificators')} + </Button> + <FormGroup> + <FormControlLabel + control={( + <Checkbox + checked={item.always} + onChange={(event) => setItem({ ...item, always: event.target.checked })} + /> + )} + label={t('notificationAlways')} + /> + </FormGroup> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedExtra')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + value={item.calendarId} + onChange={(event) => setItem({ ...item, calendarId: Number(event.target.value) })} + endpoint="/api/calendars" + label={t('sharedCalendar')} + /> + </AccordionDetails> + </Accordion> + </> + )} + </EditItemView> + ); +}; + +export default NotificationPage; diff --git a/src/settings/NotificationsPage.jsx b/src/settings/NotificationsPage.jsx new file mode 100644 index 00000000..f1e70a85 --- /dev/null +++ b/src/settings/NotificationsPage.jsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { + Table, TableRow, TableCell, TableHead, TableBody, +} from '@mui/material'; +import { useEffectAsync } from '../reactHelper'; +import { prefixString } from '../common/util/stringUtils'; +import { formatBoolean } from '../common/util/formatter'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import useSettingsStyles from './common/useSettingsStyles'; + +const NotificationsPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/notifications'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + const formatList = (prefix, value) => { + if (value) { + return value + .split(/[, ]+/) + .filter(Boolean) + .map((it) => t(prefixString(prefix, it))) + .join(', '); + } + return ''; + }; + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedNotifications']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('notificationType')}</TableCell> + <TableCell>{t('notificationAlways')}</TableCell> + <TableCell>{t('sharedAlarms')}</TableCell> + <TableCell>{t('notificationNotificators')}</TableCell> + <TableCell className={classes.columnAction} /> + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{t(prefixString('event', item.type))}</TableCell> + <TableCell>{formatBoolean(item.always, t)}</TableCell> + <TableCell>{formatList('alarm', item.attributes.alarms)}</TableCell> + <TableCell>{formatList('notificator', item.notificators)}</TableCell> + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions itemId={item.id} editPath="/settings/notification" endpoint="notifications" setTimestamp={setTimestamp} /> + </TableCell> + </TableRow> + )) : (<TableShimmer columns={5} endAction />)} + </TableBody> + </Table> + <CollectionFab editPath="/settings/notification" /> + </PageLayout> + ); +}; + +export default NotificationsPage; diff --git a/src/settings/PreferencesPage.jsx b/src/settings/PreferencesPage.jsx new file mode 100644 index 00000000..2d6df62f --- /dev/null +++ b/src/settings/PreferencesPage.jsx @@ -0,0 +1,375 @@ +import React, { useState } from 'react'; +import dayjs from 'dayjs'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { + Accordion, AccordionSummary, AccordionDetails, Typography, Container, FormControl, InputLabel, Select, MenuItem, Checkbox, FormControlLabel, FormGroup, InputAdornment, IconButton, OutlinedInput, Autocomplete, TextField, createFilterOptions, Button, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import CachedIcon from '@mui/icons-material/Cached'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { useTranslation, useTranslationKeys } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import usePositionAttributes from '../common/attributes/usePositionAttributes'; +import { prefixString, unprefixString } from '../common/util/stringUtils'; +import SelectField from '../common/components/SelectField'; +import useMapStyles from '../map/core/useMapStyles'; +import useMapOverlays from '../map/overlay/useMapOverlays'; +import { useCatch } from '../reactHelper'; +import { sessionActions } from '../store'; +import { useRestriction } from '../common/util/permissions'; +import useSettingsStyles from './common/useSettingsStyles'; + +const deviceFields = [ + { id: 'name', name: 'sharedName' }, + { id: 'uniqueId', name: 'deviceIdentifier' }, + { id: 'phone', name: 'sharedPhone' }, + { id: 'model', name: 'deviceModel' }, + { id: 'contact', name: 'deviceContact' }, +]; + +const PreferencesPage = () => { + const classes = useSettingsStyles(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const t = useTranslation(); + + const readonly = useRestriction('readonly'); + + const user = useSelector((state) => state.session.user); + const [attributes, setAttributes] = useState(user.attributes); + + const versionApp = import.meta.env.VITE_APP_VERSION.slice(0, -2); + const versionServer = useSelector((state) => state.session.server.version); + const socket = useSelector((state) => state.session.socket); + + const [token, setToken] = useState(null); + const [tokenExpiration, setTokenExpiration] = useState(dayjs().add(1, 'week').locale('en').format('YYYY-MM-DD')); + + const mapStyles = useMapStyles(); + const mapOverlays = useMapOverlays(); + + const positionAttributes = usePositionAttributes(t); + + const filter = createFilterOptions(); + + const generateToken = useCatch(async () => { + const expiration = dayjs(tokenExpiration, 'YYYY-MM-DD').toISOString(); + const response = await fetch('/api/session/token', { + method: 'POST', + body: new URLSearchParams(`expiration=${expiration}`), + }); + if (response.ok) { + setToken(await response.text()); + } else { + throw Error(await response.text()); + } + }); + + const alarms = useTranslationKeys((it) => it.startsWith('alarm')).map((it) => ({ + key: unprefixString('alarm', it), + name: t(it), + })); + + const handleSave = useCatch(async () => { + const response = await fetch(`/api/users/${user.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...user, attributes }), + }); + if (response.ok) { + dispatch(sessionActions.updateUser(await response.json())); + navigate(-1); + } else { + throw Error(await response.text()); + } + }); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedPreferences']}> + <Container maxWidth="xs" className={classes.container}> + {!readonly && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('mapTitle')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <FormControl> + <InputLabel>{t('mapActive')}</InputLabel> + <Select + label={t('mapActive')} + value={attributes.activeMapStyles?.split(',') || ['locationIqStreets', 'osm', 'carto']} + onChange={(e, child) => { + const clicked = mapStyles.find((s) => s.id === child.props.value); + if (clicked.available) { + setAttributes({ ...attributes, activeMapStyles: e.target.value.join(',') }); + } else if (clicked.id !== 'custom') { + const query = new URLSearchParams({ attribute: clicked.attribute }); + navigate(`/settings/user/${user.id}?${query.toString()}`); + } + }} + multiple + > + {mapStyles.map((style) => ( + <MenuItem key={style.id} value={style.id}> + <Typography component="span" color={style.available ? 'textPrimary' : 'error'}>{style.title}</Typography> + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('mapOverlay')}</InputLabel> + <Select + label={t('mapOverlay')} + value={attributes.selectedMapOverlay || ''} + onChange={(e) => { + const clicked = mapOverlays.find((o) => o.id === e.target.value); + if (!clicked || clicked.available) { + setAttributes({ ...attributes, selectedMapOverlay: e.target.value }); + } else if (clicked.id !== 'custom') { + const query = new URLSearchParams({ attribute: clicked.attribute }); + navigate(`/settings/user/${user.id}?${query.toString()}`); + } + }} + > + <MenuItem value="">{'\u00a0'}</MenuItem> + {mapOverlays.map((overlay) => ( + <MenuItem key={overlay.id} value={overlay.id}> + <Typography component="span" color={overlay.available ? 'textPrimary' : 'error'}>{overlay.title}</Typography> + </MenuItem> + ))} + </Select> + </FormControl> + <Autocomplete + multiple + freeSolo + options={Object.keys(positionAttributes)} + getOptionLabel={(option) => (positionAttributes[option]?.name || option)} + value={attributes.positionItems?.split(',') || ['speed', 'address', 'totalDistance', 'course']} + onChange={(_, option) => { + setAttributes({ ...attributes, positionItems: option.join(',') }); + }} + filterOptions={(options, params) => { + const filtered = filter(options, params); + if (params.inputValue && !filtered.includes(params.inputValue)) { + filtered.push(params.inputValue); + } + return filtered; + }} + renderInput={(params) => ( + <TextField + {...params} + label={t('attributePopupInfo')} + /> + )} + /> + <FormControl> + <InputLabel>{t('mapLiveRoutes')}</InputLabel> + <Select + label={t('mapLiveRoutes')} + value={attributes.mapLiveRoutes || 'none'} + onChange={(e) => setAttributes({ ...attributes, mapLiveRoutes: e.target.value })} + > + <MenuItem value="none">{t('sharedDisabled')}</MenuItem> + <MenuItem value="selected">{t('deviceSelected')}</MenuItem> + <MenuItem value="all">{t('notificationAlways')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('mapDirection')}</InputLabel> + <Select + label={t('mapDirection')} + value={attributes.mapDirection || 'selected'} + onChange={(e) => setAttributes({ ...attributes, mapDirection: e.target.value })} + > + <MenuItem value="none">{t('sharedDisabled')}</MenuItem> + <MenuItem value="selected">{t('deviceSelected')}</MenuItem> + <MenuItem value="all">{t('notificationAlways')}</MenuItem> + </Select> + </FormControl> + <FormGroup> + <FormControlLabel + control={( + <Checkbox + checked={attributes.hasOwnProperty('mapGeofences') ? attributes.mapGeofences : true} + onChange={(e) => setAttributes({ ...attributes, mapGeofences: e.target.checked })} + /> + )} + label={t('attributeShowGeofences')} + /> + <FormControlLabel + control={( + <Checkbox + checked={attributes.hasOwnProperty('mapFollow') ? attributes.mapFollow : false} + onChange={(e) => setAttributes({ ...attributes, mapFollow: e.target.checked })} + /> + )} + label={t('deviceFollow')} + /> + <FormControlLabel + control={( + <Checkbox + checked={attributes.hasOwnProperty('mapCluster') ? attributes.mapCluster : true} + onChange={(e) => setAttributes({ ...attributes, mapCluster: e.target.checked })} + /> + )} + label={t('mapClustering')} + /> + <FormControlLabel + control={( + <Checkbox + checked={attributes.hasOwnProperty('mapOnSelect') ? attributes.mapOnSelect : true} + onChange={(e) => setAttributes({ ...attributes, mapOnSelect: e.target.checked })} + /> + )} + label={t('mapOnSelect')} + /> + </FormGroup> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('deviceTitle')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + value={attributes.devicePrimary || 'name'} + onChange={(e) => setAttributes({ ...attributes, devicePrimary: e.target.value })} + data={deviceFields} + titleGetter={(it) => t(it.name)} + label={t('devicePrimaryInfo')} + /> + <SelectField + value={attributes.deviceSecondary} + onChange={(e) => setAttributes({ ...attributes, deviceSecondary: e.target.value })} + data={deviceFields} + titleGetter={(it) => t(it.name)} + label={t('deviceSecondaryInfo')} + /> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedSound')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <SelectField + multiple + value={attributes.soundEvents?.split(',') || []} + onChange={(e) => setAttributes({ ...attributes, soundEvents: e.target.value.join(',') })} + endpoint="/api/notifications/types" + keyGetter={(it) => it.type} + titleGetter={(it) => t(prefixString('event', it.type))} + label={t('eventsSoundEvents')} + /> + <SelectField + multiple + value={attributes.soundAlarms?.split(',') || ['sos']} + onChange={(e) => setAttributes({ ...attributes, soundAlarms: e.target.value.join(',') })} + data={alarms} + keyGetter={(it) => it.key} + label={t('eventsSoundAlarms')} + /> + </AccordionDetails> + </Accordion> + </> + )} + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('userToken')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + label={t('userExpirationTime')} + type="date" + value={tokenExpiration} + onChange={(e) => { + setTokenExpiration(e.target.value); + setToken(null); + }} + /> + <FormControl> + <OutlinedInput + multiline + rows={6} + readOnly + type="text" + value={token || ''} + endAdornment={( + <InputAdornment position="end"> + <div className={classes.verticalActions}> + <IconButton size="small" edge="end" onClick={generateToken} disabled={!!token}> + <CachedIcon fontSize="small" /> + </IconButton> + <IconButton size="small" edge="end" onClick={() => navigator.clipboard.writeText(token)} disabled={!token}> + <ContentCopyIcon fontSize="small" /> + </IconButton> + </div> + </InputAdornment> + )} + /> + </FormControl> + </AccordionDetails> + </Accordion> + {!readonly && ( + <> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedInfoTitle')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={versionApp} + label={t('settingsAppVersion')} + disabled + /> + <TextField + value={versionServer || '-'} + label={t('settingsServerVersion')} + disabled + /> + <TextField + value={socket ? t('deviceStatusOnline') : t('deviceStatusOffline')} + label={t('settingsConnection')} + disabled + /> + </AccordionDetails> + </Accordion> + <div className={classes.buttons}> + <Button + type="button" + color="primary" + variant="outlined" + onClick={() => navigate(-1)} + > + {t('sharedCancel')} + </Button> + <Button + type="button" + color="primary" + variant="contained" + onClick={handleSave} + > + {t('sharedSave')} + </Button> + </div> + </> + )} + </Container> + </PageLayout> + ); +}; + +export default PreferencesPage; diff --git a/src/settings/ServerPage.jsx b/src/settings/ServerPage.jsx new file mode 100644 index 00000000..0ac76334 --- /dev/null +++ b/src/settings/ServerPage.jsx @@ -0,0 +1,316 @@ +import React, { useState } from 'react'; +import TextField from '@mui/material/TextField'; + +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Button, + FormControl, + Container, + Checkbox, + FormControlLabel, + InputLabel, + Select, + MenuItem, + FormGroup, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { DropzoneArea } from 'react-mui-dropzone'; +import { sessionActions } from '../store'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import SelectField from '../common/components/SelectField'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import useCommonDeviceAttributes from '../common/attributes/useCommonDeviceAttributes'; +import useCommonUserAttributes from '../common/attributes/useCommonUserAttributes'; +import { useCatch } from '../reactHelper'; +import useServerAttributes from '../common/attributes/useServerAttributes'; +import useMapStyles from '../map/core/useMapStyles'; +import { map } from '../map/core/MapView'; +import useSettingsStyles from './common/useSettingsStyles'; + +const ServerPage = () => { + const classes = useSettingsStyles(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const t = useTranslation(); + + const mapStyles = useMapStyles(); + const commonUserAttributes = useCommonUserAttributes(t); + const commonDeviceAttributes = useCommonDeviceAttributes(t); + const serverAttributes = useServerAttributes(t); + + const original = useSelector((state) => state.session.server); + const [item, setItem] = useState({ ...original }); + + const handleFiles = useCatch(async (files) => { + if (files.length > 0) { + const file = files[0]; + const response = await fetch(`/api/server/file/${file.path}`, { + method: 'POST', + body: file, + }); + if (!response.ok) { + throw Error(await response.text()); + } + } + }); + + const handleSave = useCatch(async () => { + const response = await fetch('/api/server', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item), + }); + + if (response.ok) { + dispatch(sessionActions.updateServer(await response.json())); + navigate(-1); + } else { + throw Error(await response.text()); + } + }); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'settingsServer']}> + <Container maxWidth="xs" className={classes.container}> + {item && ( + <> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedPreferences')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.mapUrl || ''} + onChange={(event) => setItem({ ...item, mapUrl: event.target.value })} + label={t('mapCustomLabel')} + /> + <TextField + value={item.overlayUrl || ''} + onChange={(event) => setItem({ ...item, overlayUrl: event.target.value })} + label={t('mapOverlayCustom')} + /> + <FormControl> + <InputLabel>{t('mapDefault')}</InputLabel> + <Select + label={t('mapDefault')} + value={item.map || 'locationIqStreets'} + onChange={(e) => setItem({ ...item, map: e.target.value })} + > + {mapStyles.filter((style) => style.available).map((style) => ( + <MenuItem key={style.id} value={style.id}> + <Typography component="span">{style.title}</Typography> + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsCoordinateFormat')}</InputLabel> + <Select + label={t('settingsCoordinateFormat')} + value={item.coordinateFormat || 'dd'} + onChange={(event) => setItem({ ...item, coordinateFormat: event.target.value })} + > + <MenuItem value="dd">{t('sharedDecimalDegrees')}</MenuItem> + <MenuItem value="ddm">{t('sharedDegreesDecimalMinutes')}</MenuItem> + <MenuItem value="dms">{t('sharedDegreesMinutesSeconds')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsSpeedUnit')}</InputLabel> + <Select + label={t('settingsSpeedUnit')} + value={item.attributes.speedUnit || 'kn'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, speedUnit: e.target.value } })} + > + <MenuItem value="kn">{t('sharedKn')}</MenuItem> + <MenuItem value="kmh">{t('sharedKmh')}</MenuItem> + <MenuItem value="mph">{t('sharedMph')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsDistanceUnit')}</InputLabel> + <Select + label={t('settingsDistanceUnit')} + value={item.attributes.distanceUnit || 'km'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, distanceUnit: e.target.value } })} + > + <MenuItem value="km">{t('sharedKm')}</MenuItem> + <MenuItem value="mi">{t('sharedMi')}</MenuItem> + <MenuItem value="nmi">{t('sharedNmi')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsAltitudeUnit')}</InputLabel> + <Select + label={t('settingsAltitudeUnit')} + value={item.attributes.altitudeUnit || 'm'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, altitudeUnit: e.target.value } })} + > + <MenuItem value="m">{t('sharedMeters')}</MenuItem> + <MenuItem value="ft">{t('sharedFeet')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsVolumeUnit')}</InputLabel> + <Select + label={t('settingsVolumeUnit')} + value={item.attributes.volumeUnit || 'ltr'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, volumeUnit: e.target.value } })} + > + <MenuItem value="ltr">{t('sharedLiter')}</MenuItem> + <MenuItem value="usGal">{t('sharedUsGallon')}</MenuItem> + <MenuItem value="impGal">{t('sharedImpGallon')}</MenuItem> + </Select> + </FormControl> + <SelectField + value={item.attributes.timezone} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, timezone: e.target.value } })} + endpoint="/api/server/timezones" + keyGetter={(it) => it} + titleGetter={(it) => it} + label={t('sharedTimezone')} + /> + <TextField + value={item.poiLayer || ''} + onChange={(event) => setItem({ ...item, poiLayer: event.target.value })} + label={t('mapPoiLayer')} + /> + <TextField + value={item.announcement || ''} + onChange={(event) => setItem({ ...item, announcement: event.target.value })} + label={t('serverAnnouncement')} + /> + <FormGroup> + <FormControlLabel + control={<Checkbox checked={item.twelveHourFormat} onChange={(event) => setItem({ ...item, twelveHourFormat: event.target.checked })} />} + label={t('settingsTwelveHourFormat')} + /> + <FormControlLabel + control={<Checkbox checked={item.forceSettings} onChange={(event) => setItem({ ...item, forceSettings: event.target.checked })} />} + label={t('serverForceSettings')} + /> + </FormGroup> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedLocation')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + type="number" + value={item.latitude || 0} + onChange={(event) => setItem({ ...item, latitude: Number(event.target.value) })} + label={t('positionLatitude')} + /> + <TextField + type="number" + value={item.longitude || 0} + onChange={(event) => setItem({ ...item, longitude: Number(event.target.value) })} + label={t('positionLongitude')} + /> + <TextField + type="number" + value={item.zoom || 0} + onChange={(event) => setItem({ ...item, zoom: Number(event.target.value) })} + label={t('serverZoom')} + /> + <Button + variant="outlined" + color="primary" + onClick={() => { + const { lng, lat } = map.getCenter(); + setItem({ + ...item, + latitude: Number(lat.toFixed(6)), + longitude: Number(lng.toFixed(6)), + zoom: Number(map.getZoom().toFixed(1)), + }); + }} + > + {t('mapCurrentLocation')} + </Button> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedPermissions')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <FormGroup> + <FormControlLabel + control={<Checkbox checked={item.registration} onChange={(event) => setItem({ ...item, registration: event.target.checked })} />} + label={t('serverRegistration')} + /> + <FormControlLabel + control={<Checkbox checked={item.readonly} onChange={(event) => setItem({ ...item, readonly: event.target.checked })} />} + label={t('serverReadonly')} + /> + <FormControlLabel + control={<Checkbox checked={item.deviceReadonly} onChange={(event) => setItem({ ...item, deviceReadonly: event.target.checked })} />} + label={t('userDeviceReadonly')} + /> + <FormControlLabel + control={<Checkbox checked={item.limitCommands} onChange={(event) => setItem({ ...item, limitCommands: event.target.checked })} />} + label={t('userLimitCommands')} + /> + <FormControlLabel + control={<Checkbox checked={item.disableReports} onChange={(event) => setItem({ ...item, disableReports: event.target.checked })} />} + label={t('userDisableReports')} + /> + <FormControlLabel + control={<Checkbox checked={item.fixedEmail} onChange={(e) => setItem({ ...item, fixedEmail: e.target.checked })} />} + label={t('userFixedEmail')} + /> + </FormGroup> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedFile')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <DropzoneArea + dropzoneText={t('sharedDropzoneText')} + filesLimit={1} + onChange={handleFiles} + showAlerts={false} + /> + </AccordionDetails> + </Accordion> + <EditAttributesAccordion + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={{ ...commonUserAttributes, ...commonDeviceAttributes, ...serverAttributes }} + /> + </> + )} + <div className={classes.buttons}> + <Button type="button" color="primary" variant="outlined" onClick={() => navigate(-1)}> + {t('sharedCancel')} + </Button> + <Button type="button" color="primary" variant="contained" onClick={handleSave}> + {t('sharedSave')} + </Button> + </div> + </Container> + </PageLayout> + ); +}; + +export default ServerPage; diff --git a/src/settings/SharePage.jsx b/src/settings/SharePage.jsx new file mode 100644 index 00000000..d16fe44d --- /dev/null +++ b/src/settings/SharePage.jsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; +import dayjs from 'dayjs'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, + TextField, + Button, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import { useCatchCallback } from '../reactHelper'; +import useSettingsStyles from './common/useSettingsStyles'; + +const SharePage = () => { + const navigate = useNavigate(); + const classes = useSettingsStyles(); + const t = useTranslation(); + + const { id } = useParams(); + + const device = useSelector((state) => state.devices.items[id]); + + const [expiration, setExpiration] = useState(dayjs().add(1, 'week').locale('en').format('YYYY-MM-DD')); + const [link, setLink] = useState(); + + const handleShare = useCatchCallback(async () => { + const expirationTime = dayjs(expiration).toISOString(); + const response = await fetch('/api/devices/share', { + method: 'POST', + body: new URLSearchParams(`deviceId=${id}&expiration=${expirationTime}`), + }); + if (response.ok) { + const token = await response.text(); + setLink(`${window.location.origin}?token=${token}`); + } else { + throw Error(await response.text()); + } + }, [id, expiration, setLink]); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['deviceShare']}> + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={device.name} + label={t('sharedDevice')} + disabled + /> + <TextField + label={t('userExpirationTime')} + type="date" + value={(expiration && dayjs(expiration).locale('en').format('YYYY-MM-DD')) || '2099-01-01'} + onChange={(e) => setExpiration(dayjs(e.target.value, 'YYYY-MM-DD').locale('en').format())} + /> + <Button + variant="outlined" + color="primary" + onClick={handleShare} + > + {t('reportShow')} + </Button> + <TextField + value={link || ''} + onChange={(e) => setLink(e.target.value)} + label={t('sharedLink')} + InputProps={{ + readOnly: true, + }} + /> + </AccordionDetails> + </Accordion> + <div className={classes.buttons}> + <Button + type="button" + color="primary" + variant="outlined" + onClick={() => navigate(-1)} + > + {t('sharedCancel')} + </Button> + <Button + type="button" + color="primary" + variant="contained" + onClick={() => navigator.clipboard?.writeText(link)} + disabled={!link} + > + {t('sharedCopy')} + </Button> + </div> + </Container> + </PageLayout> + ); +}; + +export default SharePage; diff --git a/src/settings/UserConnectionsPage.jsx b/src/settings/UserConnectionsPage.jsx new file mode 100644 index 00000000..3ca0bdc1 --- /dev/null +++ b/src/settings/UserConnectionsPage.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Container, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import LinkField from '../common/components/LinkField'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import SettingsMenu from './components/SettingsMenu'; +import { formatNotificationTitle } from '../common/util/formatter'; +import PageLayout from '../common/components/PageLayout'; +import useSettingsStyles from './common/useSettingsStyles'; + +const UserConnectionsPage = () => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const { id } = useParams(); + + return ( + <PageLayout + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'settingsUser', 'sharedConnections']} + > + <Container maxWidth="xs" className={classes.container}> + <Accordion defaultExpanded> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedConnections')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <LinkField + endpointAll="/api/devices?all=true" + endpointLinked={`/api/devices?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="deviceId" + titleGetter={(it) => `${it.name} (${it.uniqueId})`} + label={t('deviceTitle')} + /> + <LinkField + endpointAll="/api/groups?all=true" + endpointLinked={`/api/groups?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="groupId" + label={t('settingsGroups')} + /> + <LinkField + endpointAll="/api/geofences?all=true" + endpointLinked={`/api/geofences?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="geofenceId" + label={t('sharedGeofences')} + /> + <LinkField + endpointAll="/api/notifications?all=true" + endpointLinked={`/api/notifications?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="notificationId" + titleGetter={(it) => formatNotificationTitle(t, it, true)} + label={t('sharedNotifications')} + /> + <LinkField + endpointAll="/api/calendars?all=true" + endpointLinked={`/api/calendars?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="calendarId" + label={t('sharedCalendars')} + /> + <LinkField + endpointAll="/api/users?all=true" + endpointLinked={`/api/users?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="managedUserId" + label={t('settingsUsers')} + /> + <LinkField + endpointAll="/api/attributes/computed?all=true" + endpointLinked={`/api/attributes/computed?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="attributeId" + titleGetter={(it) => it.description} + label={t('sharedComputedAttributes')} + /> + <LinkField + endpointAll="/api/drivers?all=true" + endpointLinked={`/api/drivers?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="driverId" + titleGetter={(it) => `${it.name} (${it.uniqueId})`} + label={t('sharedDrivers')} + /> + <LinkField + endpointAll="/api/commands?all=true" + endpointLinked={`/api/commands?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="commandId" + titleGetter={(it) => it.description} + label={t('sharedSavedCommands')} + /> + <LinkField + endpointAll="/api/maintenance?all=true" + endpointLinked={`/api/maintenance?userId=${id}`} + baseId={id} + keyBase="userId" + keyLink="maintenanceId" + label={t('sharedMaintenance')} + /> + </AccordionDetails> + </Accordion> + </Container> + </PageLayout> + ); +}; + +export default UserConnectionsPage; diff --git a/src/settings/UserPage.jsx b/src/settings/UserPage.jsx new file mode 100644 index 00000000..6748dd31 --- /dev/null +++ b/src/settings/UserPage.jsx @@ -0,0 +1,428 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Checkbox, + FormGroup, + TextField, + Button, + InputAdornment, + IconButton, + OutlinedInput, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; +import CachedIcon from '@mui/icons-material/Cached'; +import CloseIcon from '@mui/icons-material/Close'; +import { useDispatch, useSelector } from 'react-redux'; +import dayjs from 'dayjs'; +import EditItemView from './components/EditItemView'; +import EditAttributesAccordion from './components/EditAttributesAccordion'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import useUserAttributes from '../common/attributes/useUserAttributes'; +import { sessionActions } from '../store'; +import SelectField from '../common/components/SelectField'; +import SettingsMenu from './components/SettingsMenu'; +import useCommonUserAttributes from '../common/attributes/useCommonUserAttributes'; +import { useAdministrator, useRestriction, useManager } from '../common/util/permissions'; +import useQuery from '../common/util/useQuery'; +import { useCatch } from '../reactHelper'; +import useMapStyles from '../map/core/useMapStyles'; +import { map } from '../map/core/MapView'; +import useSettingsStyles from './common/useSettingsStyles'; + +const UserPage = () => { + const classes = useSettingsStyles(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const t = useTranslation(); + + const admin = useAdministrator(); + const manager = useManager(); + const fixedEmail = useRestriction('fixedEmail'); + + const currentUser = useSelector((state) => state.session.user); + const registrationEnabled = useSelector((state) => state.session.server.registration); + const openIdForced = useSelector((state) => state.session.server.openIdForce); + const totpEnable = useSelector((state) => state.session.server.attributes.totpEnable); + const totpForce = useSelector((state) => state.session.server.attributes.totpForce); + + const mapStyles = useMapStyles(); + const commonUserAttributes = useCommonUserAttributes(t); + const userAttributes = useUserAttributes(t); + + const { id } = useParams(); + const [item, setItem] = useState(id === currentUser.id.toString() ? currentUser : null); + + const [deleteEmail, setDeleteEmail] = useState(); + const [deleteFailed, setDeleteFailed] = useState(false); + + const handleDelete = useCatch(async () => { + if (deleteEmail === currentUser.email) { + setDeleteFailed(false); + const response = await fetch(`/api/users/${currentUser.id}`, { method: 'DELETE' }); + if (response.ok) { + navigate('/login'); + dispatch(sessionActions.updateUser(null)); + } else { + throw Error(await response.text()); + } + } else { + setDeleteFailed(true); + } + }); + + const handleGenerateTotp = useCatch(async () => { + const response = await fetch('/api/users/totp', { method: 'POST' }); + if (response.ok) { + setItem({ ...item, totpKey: await response.text() }); + } else { + throw Error(await response.text()); + } + }); + + const query = useQuery(); + const [queryHandled, setQueryHandled] = useState(false); + const attribute = query.get('attribute'); + + useEffect(() => { + if (!queryHandled && item && attribute) { + if (!item.attributes.hasOwnProperty('attribute')) { + const updatedAttributes = { ...item.attributes }; + updatedAttributes[attribute] = ''; + setItem({ ...item, attributes: updatedAttributes }); + } + setQueryHandled(true); + } + }, [item, queryHandled, setQueryHandled, attribute]); + + const onItemSaved = (result) => { + if (result.id === currentUser.id) { + dispatch(sessionActions.updateUser(result)); + } + }; + + const validate = () => item && item.name && item.email && (item.id || item.password) && (admin || !totpForce || item.totpKey); + + return ( + <EditItemView + endpoint="users" + item={item} + setItem={setItem} + defaultItem={admin ? { deviceLimit: -1 } : {}} + validate={validate} + onItemSaved={onItemSaved} + menu={<SettingsMenu />} + breadcrumbs={['settingsTitle', 'settingsUser']} + > + {item && ( + <> + <Accordion defaultExpanded={!attribute}> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedRequired')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.name || ''} + onChange={(e) => setItem({ ...item, name: e.target.value })} + label={t('sharedName')} + /> + <TextField + value={item.email || ''} + onChange={(e) => setItem({ ...item, email: e.target.value })} + label={t('userEmail')} + disabled={fixedEmail} + /> + {!openIdForced && ( + <TextField + type="password" + onChange={(e) => setItem({ ...item, password: e.target.value })} + label={t('userPassword')} + /> + )} + {totpEnable && ( + <FormControl> + <InputLabel>{t('loginTotpKey')}</InputLabel> + <OutlinedInput + readOnly + label={t('loginTotpKey')} + value={item.totpKey || ''} + endAdornment={( + <InputAdornment position="end"> + <IconButton size="small" edge="end" onClick={handleGenerateTotp}> + <CachedIcon fontSize="small" /> + </IconButton> + <IconButton size="small" edge="end" onClick={() => setItem({ ...item, totpKey: null })}> + <CloseIcon fontSize="small" /> + </IconButton> + </InputAdornment> + )} + /> + </FormControl> + )} + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedPreferences')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={item.phone || ''} + onChange={(e) => setItem({ ...item, phone: e.target.value })} + label={t('sharedPhone')} + /> + <FormControl> + <InputLabel>{t('mapDefault')}</InputLabel> + <Select + label={t('mapDefault')} + value={item.map || 'locationIqStreets'} + onChange={(e) => setItem({ ...item, map: e.target.value })} + > + {mapStyles.filter((style) => style.available).map((style) => ( + <MenuItem key={style.id} value={style.id}> + <Typography component="span">{style.title}</Typography> + </MenuItem> + ))} + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsCoordinateFormat')}</InputLabel> + <Select + label={t('settingsCoordinateFormat')} + value={item.coordinateFormat || 'dd'} + onChange={(e) => setItem({ ...item, coordinateFormat: e.target.value })} + > + <MenuItem value="dd">{t('sharedDecimalDegrees')}</MenuItem> + <MenuItem value="ddm">{t('sharedDegreesDecimalMinutes')}</MenuItem> + <MenuItem value="dms">{t('sharedDegreesMinutesSeconds')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsSpeedUnit')}</InputLabel> + <Select + label={t('settingsSpeedUnit')} + value={(item.attributes && item.attributes.speedUnit) || 'kn'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, speedUnit: e.target.value } })} + > + <MenuItem value="kn">{t('sharedKn')}</MenuItem> + <MenuItem value="kmh">{t('sharedKmh')}</MenuItem> + <MenuItem value="mph">{t('sharedMph')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsDistanceUnit')}</InputLabel> + <Select + label={t('settingsDistanceUnit')} + value={(item.attributes && item.attributes.distanceUnit) || 'km'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, distanceUnit: e.target.value } })} + > + <MenuItem value="km">{t('sharedKm')}</MenuItem> + <MenuItem value="mi">{t('sharedMi')}</MenuItem> + <MenuItem value="nmi">{t('sharedNmi')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsAltitudeUnit')}</InputLabel> + <Select + label={t('settingsAltitudeUnit')} + value={(item.attributes && item.attributes.altitudeUnit) || 'm'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, altitudeUnit: e.target.value } })} + > + <MenuItem value="m">{t('sharedMeters')}</MenuItem> + <MenuItem value="ft">{t('sharedFeet')}</MenuItem> + </Select> + </FormControl> + <FormControl> + <InputLabel>{t('settingsVolumeUnit')}</InputLabel> + <Select + label={t('settingsVolumeUnit')} + value={(item.attributes && item.attributes.volumeUnit) || 'ltr'} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, volumeUnit: e.target.value } })} + > + <MenuItem value="ltr">{t('sharedLiter')}</MenuItem> + <MenuItem value="usGal">{t('sharedUsGallon')}</MenuItem> + <MenuItem value="impGal">{t('sharedImpGallon')}</MenuItem> + </Select> + </FormControl> + <SelectField + value={item.attributes && item.attributes.timezone} + onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, timezone: e.target.value } })} + endpoint="/api/server/timezones" + keyGetter={(it) => it} + titleGetter={(it) => it} + label={t('sharedTimezone')} + /> + <TextField + value={item.poiLayer || ''} + onChange={(e) => setItem({ ...item, poiLayer: e.target.value })} + label={t('mapPoiLayer')} + /> + <FormGroup> + <FormControlLabel + control={<Checkbox checked={item.twelveHourFormat} onChange={(e) => setItem({ ...item, twelveHourFormat: e.target.checked })} />} + label={t('settingsTwelveHourFormat')} + /> + </FormGroup> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedLocation')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + type="number" + value={item.latitude || 0} + onChange={(e) => setItem({ ...item, latitude: Number(e.target.value) })} + label={t('positionLatitude')} + /> + <TextField + type="number" + value={item.longitude || 0} + onChange={(e) => setItem({ ...item, longitude: Number(e.target.value) })} + label={t('positionLongitude')} + /> + <TextField + type="number" + value={item.zoom || 0} + onChange={(e) => setItem({ ...item, zoom: Number(e.target.value) })} + label={t('serverZoom')} + /> + <Button + variant="outlined" + color="primary" + onClick={() => { + const { lng, lat } = map.getCenter(); + setItem({ + ...item, + latitude: Number(lat.toFixed(6)), + longitude: Number(lng.toFixed(6)), + zoom: Number(map.getZoom().toFixed(1)), + }); + }} + > + {t('mapCurrentLocation')} + </Button> + </AccordionDetails> + </Accordion> + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedPermissions')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + label={t('userExpirationTime')} + type="date" + value={(item.expirationTime && dayjs(item.expirationTime).locale('en').format('YYYY-MM-DD')) || '2099-01-01'} + onChange={(e) => setItem({ ...item, expirationTime: dayjs(e.target.value, 'YYYY-MM-DD').locale('en').format() })} + disabled={!manager} + /> + <TextField + type="number" + value={item.deviceLimit || 0} + onChange={(e) => setItem({ ...item, deviceLimit: Number(e.target.value) })} + label={t('userDeviceLimit')} + disabled={!admin} + /> + <TextField + type="number" + value={item.userLimit || 0} + onChange={(e) => setItem({ ...item, userLimit: Number(e.target.value) })} + label={t('userUserLimit')} + disabled={!admin} + /> + <FormGroup> + <FormControlLabel + control={<Checkbox checked={item.disabled} onChange={(e) => setItem({ ...item, disabled: e.target.checked })} />} + label={t('sharedDisabled')} + disabled={!manager} + /> + <FormControlLabel + control={<Checkbox checked={item.administrator} onChange={(e) => setItem({ ...item, administrator: e.target.checked })} />} + label={t('userAdmin')} + disabled={!admin} + /> + <FormControlLabel + control={<Checkbox checked={item.readonly} onChange={(e) => setItem({ ...item, readonly: e.target.checked })} />} + label={t('serverReadonly')} + disabled={!manager} + /> + <FormControlLabel + control={<Checkbox checked={item.deviceReadonly} onChange={(e) => setItem({ ...item, deviceReadonly: e.target.checked })} />} + label={t('userDeviceReadonly')} + disabled={!manager} + /> + <FormControlLabel + control={<Checkbox checked={item.limitCommands} onChange={(e) => setItem({ ...item, limitCommands: e.target.checked })} />} + label={t('userLimitCommands')} + disabled={!manager} + /> + <FormControlLabel + control={<Checkbox checked={item.disableReports} onChange={(e) => setItem({ ...item, disableReports: e.target.checked })} />} + label={t('userDisableReports')} + disabled={!manager} + /> + <FormControlLabel + control={<Checkbox checked={item.fixedEmail} onChange={(e) => setItem({ ...item, fixedEmail: e.target.checked })} />} + label={t('userFixedEmail')} + disabled={!manager} + /> + </FormGroup> + </AccordionDetails> + </Accordion> + <EditAttributesAccordion + attribute={attribute} + attributes={item.attributes} + setAttributes={(attributes) => setItem({ ...item, attributes })} + definitions={{ ...commonUserAttributes, ...userAttributes }} + focusAttribute={attribute} + /> + {registrationEnabled && item.id === currentUser.id && !manager && ( + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1" color="error"> + {t('userDeleteAccount')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + <TextField + value={deleteEmail} + onChange={(e) => setDeleteEmail(e.target.value)} + label={t('userEmail')} + error={deleteFailed} + /> + <Button + variant="outlined" + color="error" + onClick={handleDelete} + startIcon={<DeleteForeverIcon />} + > + {t('userDeleteAccount')} + </Button> + </AccordionDetails> + </Accordion> + )} + </> + )} + </EditItemView> + ); +}; + +export default UserPage; diff --git a/src/settings/UsersPage.jsx b/src/settings/UsersPage.jsx new file mode 100644 index 00000000..2941965b --- /dev/null +++ b/src/settings/UsersPage.jsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Table, TableRow, TableCell, TableHead, TableBody, Switch, TableFooter, FormControlLabel, +} from '@mui/material'; +import LoginIcon from '@mui/icons-material/Login'; +import LinkIcon from '@mui/icons-material/Link'; +import { useCatch, useEffectAsync } from '../reactHelper'; +import { formatBoolean, formatTime } from '../common/util/formatter'; +import { useTranslation } from '../common/components/LocalizationProvider'; +import PageLayout from '../common/components/PageLayout'; +import SettingsMenu from './components/SettingsMenu'; +import CollectionFab from './components/CollectionFab'; +import CollectionActions from './components/CollectionActions'; +import TableShimmer from '../common/components/TableShimmer'; +import { useManager } from '../common/util/permissions'; +import SearchHeader, { filterByKeyword } from './components/SearchHeader'; +import { usePreference } from '../common/util/preferences'; +import useSettingsStyles from './common/useSettingsStyles'; + +const UsersPage = () => { + const classes = useSettingsStyles(); + const navigate = useNavigate(); + const t = useTranslation(); + + const manager = useManager(); + + const hours12 = usePreference('twelveHourFormat'); + + const [timestamp, setTimestamp] = useState(Date.now()); + const [items, setItems] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [loading, setLoading] = useState(false); + const [temporary, setTemporary] = useState(false); + + const handleLogin = useCatch(async (userId) => { + const response = await fetch(`/api/session/${userId}`); + if (response.ok) { + window.location.replace('/'); + } else { + throw Error(await response.text()); + } + }); + + const actionLogin = { + key: 'login', + title: t('loginLogin'), + icon: <LoginIcon fontSize="small" />, + handler: handleLogin, + }; + + const actionConnections = { + key: 'connections', + title: t('sharedConnections'), + icon: <LinkIcon fontSize="small" />, + handler: (userId) => navigate(`/settings/user/${userId}/connections`), + }; + + useEffectAsync(async () => { + setLoading(true); + try { + const response = await fetch('/api/users'); + if (response.ok) { + setItems(await response.json()); + } else { + throw Error(await response.text()); + } + } finally { + setLoading(false); + } + }, [timestamp]); + + return ( + <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'settingsUsers']}> + <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} /> + <Table className={classes.table}> + <TableHead> + <TableRow> + <TableCell>{t('sharedName')}</TableCell> + <TableCell>{t('userEmail')}</TableCell> + <TableCell>{t('userAdmin')}</TableCell> + <TableCell>{t('sharedDisabled')}</TableCell> + <TableCell>{t('userExpirationTime')}</TableCell> + <TableCell className={classes.columnAction} /> + </TableRow> + </TableHead> + <TableBody> + {!loading ? items.filter((u) => temporary || !u.temporary).filter(filterByKeyword(searchKeyword)).map((item) => ( + <TableRow key={item.id}> + <TableCell>{item.name}</TableCell> + <TableCell>{item.email}</TableCell> + <TableCell>{formatBoolean(item.administrator, t)}</TableCell> + <TableCell>{formatBoolean(item.disabled, t)}</TableCell> + <TableCell>{formatTime(item.expirationTime, 'date', hours12)}</TableCell> + <TableCell className={classes.columnAction} padding="none"> + <CollectionActions + itemId={item.id} + editPath="/settings/user" + endpoint="users" + setTimestamp={setTimestamp} + customActions={manager ? [actionLogin, actionConnections] : [actionConnections]} + /> + </TableCell> + </TableRow> + )) : (<TableShimmer columns={6} endAction />)} + </TableBody> + <TableFooter> + <TableRow> + <TableCell colSpan={6} align="right"> + <FormControlLabel + control={( + <Switch + value={temporary} + onChange={(e) => setTemporary(e.target.checked)} + size="small" + /> + )} + label={t('userTemporary')} + labelPlacement="start" + /> + </TableCell> + </TableRow> + </TableFooter> + </Table> + <CollectionFab editPath="/settings/user" /> + </PageLayout> + ); +}; + +export default UsersPage; diff --git a/src/settings/common/useSettingsStyles.js b/src/settings/common/useSettingsStyles.js new file mode 100644 index 00000000..b276e0b7 --- /dev/null +++ b/src/settings/common/useSettingsStyles.js @@ -0,0 +1,33 @@ +import { makeStyles } from '@mui/styles'; + +export default makeStyles((theme) => ({ + table: { + marginBottom: theme.spacing(10), + }, + columnAction: { + width: '1%', + paddingRight: theme.spacing(1), + }, + container: { + marginTop: theme.spacing(2), + }, + buttons: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + display: 'flex', + justifyContent: 'space-evenly', + '& > *': { + flexBasis: '33%', + }, + }, + details: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + paddingBottom: theme.spacing(3), + }, + verticalActions: { + display: 'flex', + flexDirection: 'column', + }, +})); diff --git a/src/settings/components/AddAttributeDialog.jsx b/src/settings/components/AddAttributeDialog.jsx new file mode 100644 index 00000000..86ff64ea --- /dev/null +++ b/src/settings/components/AddAttributeDialog.jsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { + Button, Dialog, DialogActions, DialogContent, FormControl, InputLabel, MenuItem, Select, TextField, Autocomplete, +} from '@mui/material'; + +import { createFilterOptions } from '@mui/material/useAutocomplete'; +import { makeStyles } from '@mui/styles'; +import { useTranslation } from '../../common/components/LocalizationProvider'; + +const useStyles = makeStyles((theme) => ({ + details: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + paddingBottom: theme.spacing(1), + paddingTop: theme.spacing(3), + }, +})); + +const AddAttributeDialog = ({ open, onResult, definitions }) => { + const classes = useStyles(); + const t = useTranslation(); + + const filter = createFilterOptions({ + stringify: (option) => option.name, + }); + + const options = Object.entries(definitions).map(([key, value]) => ({ + key, + name: value.name, + type: value.type, + })); + + const [key, setKey] = useState(); + const [type, setType] = useState('string'); + + return ( + <Dialog open={open} fullWidth maxWidth="xs"> + <DialogContent className={classes.details}> + <Autocomplete + onChange={(_, option) => { + setKey(option && typeof option === 'object' ? option.key : option); + if (option && option.type) { + setType(option.type); + } + }} + filterOptions={(options, params) => { + const filtered = filter(options, params); + if (params.inputValue) { + filtered.push({ + key: params.inputValue, + name: params.inputValue, + }); + } + return filtered; + }} + options={options} + getOptionLabel={(option) => (option && typeof option === 'object' ? option.name : option)} + renderOption={(props, option) => ( + <li {...props}> + {option.name} + </li> + )} + renderInput={(params) => ( + <TextField {...params} label={t('sharedAttribute')} /> + )} + freeSolo + /> + <FormControl + fullWidth + disabled={key in definitions} + > + <InputLabel>{t('sharedType')}</InputLabel> + <Select + label={t('sharedType')} + value={type} + onChange={(e) => setType(e.target.value)} + > + <MenuItem value="string">{t('sharedTypeString')}</MenuItem> + <MenuItem value="number">{t('sharedTypeNumber')}</MenuItem> + <MenuItem value="boolean">{t('sharedTypeBoolean')}</MenuItem> + </Select> + </FormControl> + </DialogContent> + <DialogActions> + <Button + color="primary" + disabled={!key} + onClick={() => onResult({ key, type })} + > + {t('sharedAdd')} + </Button> + <Button + autoFocus + onClick={() => onResult(null)} + > + {t('sharedCancel')} + </Button> + </DialogActions> + </Dialog> + ); +}; + +export default AddAttributeDialog; diff --git a/src/settings/components/BaseCommandView.jsx b/src/settings/components/BaseCommandView.jsx new file mode 100644 index 00000000..bb70c3b9 --- /dev/null +++ b/src/settings/components/BaseCommandView.jsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { + TextField, FormControlLabel, Checkbox, +} from '@mui/material'; +import { useTranslation } from '../../common/components/LocalizationProvider'; +import SelectField from '../../common/components/SelectField'; +import { prefixString } from '../../common/util/stringUtils'; +import useCommandAttributes from '../../common/attributes/useCommandAttributes'; + +const BaseCommandView = ({ deviceId, item, setItem }) => { + const t = useTranslation(); + + const textEnabled = useSelector((state) => state.session.server.textEnabled); + + const availableAttributes = useCommandAttributes(t); + + const [attributes, setAttributes] = useState([]); + + useEffect(() => { + if (item && item.type) { + setAttributes(availableAttributes[item.type] || []); + } else { + setAttributes([]); + } + }, [availableAttributes, item]); + + return ( + <> + <SelectField + value={item.type} + onChange={(e) => setItem({ ...item, type: e.target.value, attributes: {} })} + endpoint={deviceId ? `/api/commands/types?${new URLSearchParams({ deviceId }).toString()}` : '/api/commands/types'} + keyGetter={(it) => it.type} + titleGetter={(it) => t(prefixString('command', it.type))} + label={t('sharedType')} + /> + {attributes.map(({ key, name, type }) => { + if (type === 'boolean') { + return ( + <FormControlLabel + control={( + <Checkbox + checked={item.attributes[key]} + onChange={(e) => { + const updateItem = { ...item, attributes: { ...item.attributes } }; + updateItem.attributes[key] = e.target.checked; + setItem(updateItem); + }} + /> + )} + label={name} + /> + ); + } + return ( + <TextField + type={type === 'number' ? 'number' : 'text'} + value={item.attributes[key]} + onChange={(e) => { + const updateItem = { ...item, attributes: { ...item.attributes } }; + updateItem.attributes[key] = type === 'number' ? Number(e.target.value) : e.target.value; + setItem(updateItem); + }} + label={name} + /> + ); + })} + {textEnabled && ( + <FormControlLabel + control={<Checkbox checked={item.textChannel} onChange={(event) => setItem({ ...item, textChannel: event.target.checked })} />} + label={t('commandSendSms')} + /> + )} + </> + ); +}; + +export default BaseCommandView; diff --git a/src/settings/components/CollectionActions.jsx b/src/settings/components/CollectionActions.jsx new file mode 100644 index 00000000..666052d5 --- /dev/null +++ b/src/settings/components/CollectionActions.jsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { + IconButton, Menu, MenuItem, useMediaQuery, useTheme, +} from '@mui/material'; +import Tooltip from '@mui/material/Tooltip'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { useNavigate } from 'react-router-dom'; +import { makeStyles } from '@mui/styles'; +import RemoveDialog from '../../common/components/RemoveDialog'; +import { useTranslation } from '../../common/components/LocalizationProvider'; + +const useStyles = makeStyles(() => ({ + row: { + display: 'flex', + }, +})); + +const CollectionActions = ({ + itemId, editPath, endpoint, setTimestamp, customActions, readonly, +}) => { + const theme = useTheme(); + const classes = useStyles(); + const navigate = useNavigate(); + const t = useTranslation(); + + const phone = useMediaQuery(theme.breakpoints.down('sm')); + + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [removing, setRemoving] = useState(false); + + const handleEdit = () => { + navigate(`${editPath}/${itemId}`); + setMenuAnchorEl(null); + }; + + const handleRemove = () => { + setRemoving(true); + setMenuAnchorEl(null); + }; + + const handleCustom = (action) => { + action.handler(itemId); + setMenuAnchorEl(null); + }; + + const handleRemoveResult = (removed) => { + setRemoving(false); + if (removed) { + setTimestamp(Date.now()); + } + }; + + return ( + <> + {phone ? ( + <> + <IconButton size="small" onClick={(event) => setMenuAnchorEl(event.currentTarget)}> + <MoreVertIcon fontSize="small" /> + </IconButton> + <Menu open={!!menuAnchorEl} anchorEl={menuAnchorEl} onClose={() => setMenuAnchorEl(null)}> + {customActions && customActions.map((action) => ( + <MenuItem onClick={() => handleCustom(action)} key={action.key}>{action.title}</MenuItem> + ))} + {!readonly && ( + <> + <MenuItem onClick={handleEdit}>{t('sharedEdit')}</MenuItem> + <MenuItem onClick={handleRemove}>{t('sharedRemove')}</MenuItem> + </> + )} + </Menu> + </> + ) : ( + <div className={classes.row}> + {customActions && customActions.map((action) => ( + <Tooltip title={action.title} key={action.key}> + <IconButton size="small" onClick={() => handleCustom(action)}> + {action.icon} + </IconButton> + </Tooltip> + ))} + {!readonly && ( + <> + <Tooltip title={t('sharedEdit')}> + <IconButton size="small" onClick={handleEdit}> + <EditIcon fontSize="small" /> + </IconButton> + </Tooltip> + <Tooltip title={t('sharedRemove')}> + <IconButton size="small" onClick={handleRemove}> + <DeleteIcon fontSize="small" /> + </IconButton> + </Tooltip> + </> + )} + </div> + )} + <RemoveDialog style={{ transform: 'none' }} open={removing} endpoint={endpoint} itemId={itemId} onResult={handleRemoveResult} /> + </> + ); +}; + +export default CollectionActions; diff --git a/src/settings/components/CollectionFab.jsx b/src/settings/components/CollectionFab.jsx new file mode 100644 index 00000000..3c1fa783 --- /dev/null +++ b/src/settings/components/CollectionFab.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Fab } from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import AddIcon from '@mui/icons-material/Add'; +import { useNavigate } from 'react-router-dom'; +import { useRestriction } from '../../common/util/permissions'; + +const useStyles = makeStyles((theme) => ({ + fab: { + position: 'fixed', + bottom: theme.spacing(2), + right: theme.spacing(2), + [theme.breakpoints.down('md')]: { + bottom: `calc(${theme.dimensions.bottomBarHeight}px + ${theme.spacing(2)})`, + }, + }, +})); + +const CollectionFab = ({ editPath, disabled }) => { + const classes = useStyles(); + const navigate = useNavigate(); + + const readonly = useRestriction('readonly'); + + if (!readonly && !disabled) { + return ( + <Fab size="medium" color="primary" className={classes.fab} onClick={() => navigate(editPath)}> + <AddIcon /> + </Fab> + ); + } + return ''; +}; + +export default CollectionFab; diff --git a/src/settings/components/EditAttributesAccordion.jsx b/src/settings/components/EditAttributesAccordion.jsx new file mode 100644 index 00000000..4d4ae254 --- /dev/null +++ b/src/settings/components/EditAttributesAccordion.jsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react'; + +import { + Button, + Checkbox, + OutlinedInput, + FormControl, + FormControlLabel, + Grid, + IconButton, + InputAdornment, + InputLabel, + Accordion, + AccordionSummary, + Typography, + AccordionDetails, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import AddIcon from '@mui/icons-material/Add'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import AddAttributeDialog from './AddAttributeDialog'; +import { useTranslation } from '../../common/components/LocalizationProvider'; +import { useAttributePreference } from '../../common/util/preferences'; +import { + distanceFromMeters, distanceToMeters, distanceUnitString, speedFromKnots, speedToKnots, speedUnitString, volumeFromLiters, volumeToLiters, volumeUnitString, +} from '../../common/util/converter'; +import useFeatures from '../../common/util/useFeatures'; +import useSettingsStyles from '../common/useSettingsStyles'; + +const EditAttributesAccordion = ({ attribute, attributes, setAttributes, definitions, focusAttribute }) => { + const classes = useSettingsStyles(); + const t = useTranslation(); + + const features = useFeatures(); + + const speedUnit = useAttributePreference('speedUnit'); + const distanceUnit = useAttributePreference('distanceUnit'); + const volumeUnit = useAttributePreference('volumeUnit'); + + const [addDialogShown, setAddDialogShown] = useState(false); + + const updateAttribute = (key, value, type, subtype) => { + const updatedAttributes = { ...attributes }; + switch (subtype) { + case 'speed': + updatedAttributes[key] = speedToKnots(Number(value), speedUnit); + break; + case 'distance': + updatedAttributes[key] = distanceToMeters(Number(value), distanceUnit); + break; + case 'volume': + updatedAttributes[key] = volumeToLiters(Number(value), volumeUnit); + break; + default: + updatedAttributes[key] = type === 'number' ? Number(value) : value; + break; + } + setAttributes(updatedAttributes); + }; + + const deleteAttribute = (key) => { + const updatedAttributes = { ...attributes }; + delete updatedAttributes[key]; + setAttributes(updatedAttributes); + }; + + const getAttributeName = (key, subtype) => { + const definition = definitions[key]; + const name = definition ? definition.name : key; + switch (subtype) { + case 'speed': + return `${name} (${speedUnitString(speedUnit, t)})`; + case 'distance': + return `${name} (${distanceUnitString(distanceUnit, t)})`; + case 'volume': + return `${name} (${volumeUnitString(volumeUnit, t)})`; + default: + return name; + } + }; + + const getAttributeType = (value) => { + if (typeof value === 'number') { + return 'number'; + } if (typeof value === 'boolean') { + return 'boolean'; + } + return 'string'; + }; + + const getAttributeSubtype = (key) => { + const definition = definitions[key]; + return definition && definition.subtype; + }; + + const getDisplayValue = (value, subtype) => { + if (value) { + switch (subtype) { + case 'speed': + return speedFromKnots(value, speedUnit); + case 'distance': + return distanceFromMeters(value, distanceUnit); + case 'volume': + return volumeFromLiters(value, volumeUnit); + default: + return value; + } + } + return ''; + }; + + const convertToList = (attributes) => { + const booleanList = []; + const otherList = []; + const excludeAttributes = ['speedUnit', 'distanceUnit', 'volumeUnit', 'timezone']; + Object.keys(attributes || []).filter((key) => !excludeAttributes.includes(key)).forEach((key) => { + const value = attributes[key]; + const type = getAttributeType(value); + const subtype = getAttributeSubtype(key); + if (type === 'boolean') { + booleanList.push({ + key, value, type, subtype, + }); + } else { + otherList.push({ + key, value, type, subtype, + }); + } + }); + return [...otherList, ...booleanList]; + }; + + const handleAddResult = (definition) => { + setAddDialogShown(false); + if (definition) { + switch (definition.type) { + case 'number': + updateAttribute(definition.key, 0); + break; + case 'boolean': + updateAttribute(definition.key, false); + break; + default: + updateAttribute(definition.key, ''); + break; + } + } + }; + + return features.disableAttributes ? '' : ( + <Accordion defaultExpanded={!!attribute}> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography variant="subtitle1"> + {t('sharedAttributes')} + </Typography> + </AccordionSummary> + <AccordionDetails className={classes.details}> + {convertToList(attributes).map(({ + key, value, type, subtype, + }) => { + if (type === 'boolean') { + return ( + <Grid container direction="row" justifyContent="space-between" key={key}> + <FormControlLabel + control={( + <Checkbox + checked={value} + onChange={(e) => updateAttribute(key, e.target.checked)} + /> + )} + label={getAttributeName(key, subtype)} + /> + <IconButton size="small" className={classes.removeButton} onClick={() => deleteAttribute(key)}> + <CloseIcon fontSize="small" /> + </IconButton> + </Grid> + ); + } + return ( + <FormControl key={key}> + <InputLabel>{getAttributeName(key, subtype)}</InputLabel> + <OutlinedInput + label={getAttributeName(key, subtype)} + type={type === 'number' ? 'number' : 'text'} + value={getDisplayValue(value, subtype)} + onChange={(e) => updateAttribute(key, e.target.value, type, subtype)} + autoFocus={focusAttribute === key} + endAdornment={( + <InputAdornment position="end"> + <IconButton size="small" edge="end" onClick={() => deleteAttribute(key)}> + <CloseIcon fontSize="small" /> + </IconButton> + </InputAdornment> + )} + /> + </FormControl> + ); + })} + <Button + variant="outlined" + color="primary" + onClick={() => setAddDialogShown(true)} + startIcon={<AddIcon />} + > + {t('sharedAdd')} + </Button> + <AddAttributeDialog + open={addDialogShown} + onResult={handleAddResult} + definitions={definitions} + /> + </AccordionDetails> + </Accordion> + ); +}; + +export default EditAttributesAccordion; diff --git a/src/settings/components/EditItemView.jsx b/src/settings/components/EditItemView.jsx new file mode 100644 index 00000000..61bc4161 --- /dev/null +++ b/src/settings/components/EditItemView.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Container, Button, Accordion, AccordionDetails, AccordionSummary, Skeleton, Typography, TextField, +} from '@mui/material'; +import { useCatch, useEffectAsync } from '../../reactHelper'; +import { useTranslation } from '../../common/components/LocalizationProvider'; +import PageLayout from '../../common/components/PageLayout'; +import useSettingsStyles from '../common/useSettingsStyles'; + +const EditItemView = ({ + children, endpoint, item, setItem, defaultItem, validate, onItemSaved, menu, breadcrumbs, +}) => { + const navigate = useNavigate(); + const classes = useSettingsStyles(); + const t = useTranslation(); + + const { id } = useParams(); + + useEffectAsync(async () => { + if (!item) { + if (id) { + const response = await fetch(`/api/${endpoint}/${id}`); + if (response.ok) { + setItem(await response.json()); + } else { + throw Error(await response.text()); + } + } else { + setItem(defaultItem || {}); + } + } + }, [id, item, defaultItem]); + + const handleSave = useCatch(async () => { + let url = `/api/${endpoint}`; + if (id) { + url += `/${id}`; + } + + const response = await fetch(url, { + method: !id ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item), + }); + + if (response.ok) { + if (onItemSaved) { + onItemSaved(await response.json()); + } + navigate(-1); + } else { + throw Error(await response.text()); + } + }); + + return ( + <PageLayout menu={menu} breadcrumbs={breadcrumbs}> + <Container maxWidth="xs" className={classes.container}> + {item ? children : ( + <Accordion defaultExpanded> + <AccordionSummary> + <Typography variant="subtitle1"> + <Skeleton width="10em" /> + </Typography> + </AccordionSummary> + <AccordionDetails> + {[...Array(3)].map((_, i) => ( + <Skeleton key={-i} width="100%"> + <TextField /> + </Skeleton> + ))} + </AccordionDetails> + </Accordion> + )} + <div className={classes.buttons}> + <Button + type="button" + color="primary" + variant="outlined" + onClick={() => navigate(-1)} + disabled={!item} + > + {t('sharedCancel')} + </Button> + <Button + type="button" + color="primary" + variant="contained" + onClick={handleSave} + disabled={!item || !validate()} + > + {t('sharedSave')} + </Button> + </div> + </Container> + </PageLayout> + ); +}; + +export default EditItemView; diff --git a/src/settings/components/SearchHeader.jsx b/src/settings/components/SearchHeader.jsx new file mode 100644 index 00000000..25757ed2 --- /dev/null +++ b/src/settings/components/SearchHeader.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { TextField, useTheme, useMediaQuery } from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import { useTranslation } from '../../common/components/LocalizationProvider'; + +export const filterByKeyword = (keyword) => (item) => !keyword || JSON.stringify(item).toLowerCase().includes(keyword.toLowerCase()); + +const useStyles = makeStyles((theme) => ({ + header: { + position: 'sticky', + left: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + padding: theme.spacing(3, 2, 2), + }, +})); + +const SearchHeader = ({ keyword, setKeyword }) => { + const theme = useTheme(); + const classes = useStyles(); + const t = useTranslation(); + + const phone = useMediaQuery(theme.breakpoints.down('sm')); + + return phone ? ( + <div className={classes.header}> + <TextField + variant="outlined" + placeholder={t('sharedSearch')} + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + /> + </div> + ) : ''; +}; + +export default SearchHeader; diff --git a/src/settings/components/SettingsMenu.jsx b/src/settings/components/SettingsMenu.jsx new file mode 100644 index 00000000..7085d47a --- /dev/null +++ b/src/settings/components/SettingsMenu.jsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { + Divider, List, ListItemButton, ListItemIcon, ListItemText, +} from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; +import CreateIcon from '@mui/icons-material/Create'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import FolderIcon from '@mui/icons-material/Folder'; +import PersonIcon from '@mui/icons-material/Person'; +import StorageIcon from '@mui/icons-material/Storage'; +import BuildIcon from '@mui/icons-material/Build'; +import PeopleIcon from '@mui/icons-material/People'; +import TodayIcon from '@mui/icons-material/Today'; +import PublishIcon from '@mui/icons-material/Publish'; +import SmartphoneIcon from '@mui/icons-material/Smartphone'; +import HelpIcon from '@mui/icons-material/Help'; +import CampaignIcon from '@mui/icons-material/Campaign'; +import { Link, useLocation } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { useTranslation } from '../../common/components/LocalizationProvider'; +import { + useAdministrator, useManager, useRestriction, +} from '../../common/util/permissions'; +import useFeatures from '../../common/util/useFeatures'; + +const MenuItem = ({ + title, link, icon, selected, +}) => ( + <ListItemButton key={link} component={Link} to={link} selected={selected}> + <ListItemIcon>{icon}</ListItemIcon> + <ListItemText primary={title} /> + </ListItemButton> +); + +const SettingsMenu = () => { + const t = useTranslation(); + const location = useLocation(); + + const readonly = useRestriction('readonly'); + const admin = useAdministrator(); + const manager = useManager(); + const userId = useSelector((state) => state.session.user.id); + const supportLink = useSelector((state) => state.session.server.attributes.support); + + const features = useFeatures(); + + return ( + <> + <List> + <MenuItem + title={t('sharedPreferences')} + link="/settings/preferences" + icon={<SettingsIcon />} + selected={location.pathname === '/settings/preferences'} + /> + {!readonly && ( + <> + <MenuItem + title={t('sharedNotifications')} + link="/settings/notifications" + icon={<NotificationsIcon />} + selected={location.pathname.startsWith('/settings/notification')} + /> + <MenuItem + title={t('settingsUser')} + link={`/settings/user/${userId}`} + icon={<PersonIcon />} + selected={location.pathname === `/settings/user/${userId}`} + /> + <MenuItem + title={t('deviceTitle')} + link="/settings/devices" + icon={<SmartphoneIcon />} + selected={location.pathname.startsWith('/settings/device')} + /> + <MenuItem + title={t('sharedGeofences')} + link="/geofences" + icon={<CreateIcon />} + selected={location.pathname.startsWith('/settings/geofence')} + /> + {!features.disableGroups && ( + <MenuItem + title={t('settingsGroups')} + link="/settings/groups" + icon={<FolderIcon />} + selected={location.pathname.startsWith('/settings/group')} + /> + )} + {!features.disableDrivers && ( + <MenuItem + title={t('sharedDrivers')} + link="/settings/drivers" + icon={<PersonIcon />} + selected={location.pathname.startsWith('/settings/driver')} + /> + )} + {!features.disableCalendars && ( + <MenuItem + title={t('sharedCalendars')} + link="/settings/calendars" + icon={<TodayIcon />} + selected={location.pathname.startsWith('/settings/calendar')} + /> + )} + {!features.disableComputedAttributes && ( + <MenuItem + title={t('sharedComputedAttributes')} + link="/settings/attributes" + icon={<StorageIcon />} + selected={location.pathname.startsWith('/settings/attribute')} + /> + )} + {!features.disableMaintenance && ( + <MenuItem + title={t('sharedMaintenance')} + link="/settings/maintenances" + icon={<BuildIcon />} + selected={location.pathname.startsWith('/settings/maintenance')} + /> + )} + {!features.disableSavedCommands && ( + <MenuItem + title={t('sharedSavedCommands')} + link="/settings/commands" + icon={<PublishIcon />} + selected={location.pathname.startsWith('/settings/command')} + /> + )} + {supportLink && ( + <MenuItem + title={t('settingsSupport')} + link={supportLink} + icon={<HelpIcon />} + /> + )} + </> + )} + </List> + {manager && ( + <> + <Divider /> + <List> + {admin && ( + <> + <MenuItem + title={t('serverAnnouncement')} + link="/settings/announcement" + icon={<CampaignIcon />} + selected={location.pathname === '/settings/announcement'} + /> + <MenuItem + title={t('settingsServer')} + link="/settings/server" + icon={<StorageIcon />} + selected={location.pathname === '/settings/server'} + /> + </> + )} + <MenuItem + title={t('settingsUsers')} + link="/settings/users" + icon={<PeopleIcon />} + selected={location.pathname.startsWith('/settings/user') && location.pathname !== `/settings/user/${userId}`} + /> + </List> + </> + )} + </> + ); +}; + +export default SettingsMenu; |