warning overview page for sodistore home and pro
This commit is contained in:
parent
ec0e8258b2
commit
aefeb75641
|
|
@ -155,6 +155,119 @@ public class Controller : ControllerBase
|
|||
.ToList();
|
||||
}
|
||||
|
||||
// Admin-only centralized alarm log across all accessible installations.
|
||||
// Server-side filtering (product/device/severity/date/installation); the frontend
|
||||
// does the grouping (Installation+Device+Alarm+Severity) and sorting.
|
||||
// NOTE: the underlying Error/Warning tables keep only the last ~100 rows per
|
||||
// installation (see Db.HandleError/HandleWarning), so this report reflects
|
||||
// currently-retained alarms, not a guaranteed full history.
|
||||
[HttpGet(nameof(GetAlarmReport))]
|
||||
public ActionResult<IEnumerable<AlarmReportRow>> GetAlarmReport(Token authToken,
|
||||
String? products = null,
|
||||
String? devices = null,
|
||||
String severity = "both",
|
||||
String? from = null,
|
||||
String? to = null,
|
||||
Int64? installationId = null)
|
||||
{
|
||||
var user = Db.GetSession(authToken)?.User;
|
||||
if (user is null || user.UserType != 2) // admins only
|
||||
return Unauthorized();
|
||||
|
||||
var productSet = ParseIntCsv(products);
|
||||
var deviceSet = ParseIntCsv(devices);
|
||||
|
||||
// This is a battery alarm report for the Sodistore series only:
|
||||
// products Sodistore Home + Pro, devices Growatt (3) + Sinexcel (4).
|
||||
var allowedProducts = new HashSet<int> { (int)ProductType.SodioHome, (int)ProductType.SodistorePro };
|
||||
var allowedDevices = new HashSet<int> { 3, 4 };
|
||||
|
||||
var installations = user
|
||||
.AccessibleInstallations()
|
||||
.Where(i => allowedProducts.Contains(i.Product))
|
||||
.Where(i => allowedDevices.Contains(i.Device))
|
||||
.Where(i => productSet is null || productSet.Contains(i.Product))
|
||||
.Where(i => deviceSet is null || deviceSet.Contains(i.Device))
|
||||
.Where(i => installationId is null || i.Id == installationId)
|
||||
.ToList();
|
||||
|
||||
// GroupBy (not a plain ToDictionary): AccessibleInstallations() concats direct +
|
||||
// folder-derived installations and .Distinct()s by reference, so the same Id can
|
||||
// appear as two instances. GroupBy collapses them and avoids a duplicate-key throw.
|
||||
var byId = installations
|
||||
.GroupBy(i => i.Id)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var ids = byId.Keys.ToHashSet();
|
||||
if (ids.Count == 0)
|
||||
return new List<AlarmReportRow>();
|
||||
|
||||
var rows = new List<AlarmReportRow>();
|
||||
|
||||
var wantErrors = severity is "both" or "error";
|
||||
var wantWarnings = severity is "both" or "warning";
|
||||
|
||||
// Query per accessible installation so sqlite-net pushes "InstallationId == x"
|
||||
// down to SQL (HashSet.Contains can't be translated and would scan the whole table).
|
||||
foreach (var instId in ids)
|
||||
{
|
||||
if (wantErrors)
|
||||
rows.AddRange(Db.Errors
|
||||
.Where(e => e.InstallationId == instId)
|
||||
.AsEnumerable()
|
||||
.Select(e => ToReportRow(e, "error", byId)));
|
||||
|
||||
if (wantWarnings)
|
||||
rows.AddRange(Db.Warnings
|
||||
.Where(w => w.InstallationId == instId)
|
||||
.AsEnumerable()
|
||||
.Select(w => ToReportRow(w, "warning", byId)));
|
||||
}
|
||||
|
||||
// Date stored as "yyyy-MM-dd": lexical compare == chronological, no parsing needed.
|
||||
if (!String.IsNullOrWhiteSpace(from))
|
||||
rows = rows.Where(r => String.Compare(r.Date, from, StringComparison.Ordinal) >= 0).ToList();
|
||||
if (!String.IsNullOrWhiteSpace(to))
|
||||
rows = rows.Where(r => String.Compare(r.Date, to, StringComparison.Ordinal) <= 0).ToList();
|
||||
|
||||
return rows
|
||||
.OrderByDescending(r => r.Date)
|
||||
.ThenByDescending(r => r.Time)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static AlarmReportRow ToReportRow(LogEntry entry, String severity, IReadOnlyDictionary<Int64, Installation> byId)
|
||||
{
|
||||
var installation = byId.TryGetValue(entry.InstallationId, out var inst) ? inst : null;
|
||||
return new AlarmReportRow
|
||||
{
|
||||
Id = entry.Id,
|
||||
InstallationId = entry.InstallationId,
|
||||
InstallationName = installation?.Name ?? "",
|
||||
Product = installation?.Product ?? -1,
|
||||
Device = installation?.Device ?? -1,
|
||||
Severity = severity,
|
||||
Description = entry.Description,
|
||||
Date = entry.Date,
|
||||
Time = entry.Time,
|
||||
DeviceCreatedTheMessage = entry.DeviceCreatedTheMessage,
|
||||
Seen = entry.Seen,
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<int>? ParseIntCsv(String? csv)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(csv))
|
||||
return null;
|
||||
|
||||
return csv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => int.TryParse(s, out var v) ? (int?)v : null)
|
||||
.Where(v => v.HasValue)
|
||||
.Select(v => v!.Value)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpGet(nameof(GetCsvTimestampsForInstallation))]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
// Read-only DTO returned by GetAlarmReport. Not a database table.
|
||||
// One row per raw alarm/warning occurrence, enriched with installation metadata
|
||||
// so the frontend can group/filter across installations without extra round-trips.
|
||||
public class AlarmReportRow
|
||||
{
|
||||
public Int64 Id { get; set; }
|
||||
public Int64 InstallationId { get; set; }
|
||||
public String InstallationName { get; set; } = "";
|
||||
public int Product { get; set; }
|
||||
public int Device { get; set; }
|
||||
public String Severity { get; set; } = ""; // "error" | "warning"
|
||||
public String Description { get; set; } = ""; // camelCase alarm code, e.g. "Battery2Undervoltage"
|
||||
public String Date { get; set; } = ""; // "yyyy-MM-dd" (Swiss local time, as stamped by the device)
|
||||
public String Time { get; set; } = ""; // "HH:mm:ss" (Swiss local time)
|
||||
public String DeviceCreatedTheMessage { get; set; } = ""; // e.g. "Inverter 1"
|
||||
public Boolean Seen { get; set; }
|
||||
}
|
||||
|
|
@ -119,6 +119,7 @@ function App() {
|
|||
|
||||
const Login = Loader(lazy(() => import('src/components/login')));
|
||||
const Users = Loader(lazy(() => import('src/content/dashboards/Users')));
|
||||
const Report = Loader(lazy(() => import('src/content/dashboards/Report')));
|
||||
|
||||
useEffect(() => {
|
||||
if (!username || token) return;
|
||||
|
|
@ -273,6 +274,7 @@ function App() {
|
|||
/>
|
||||
|
||||
<Route path={routes.tickets + '*'} element={<Tickets />} />
|
||||
<Route path={routes.alarmReport + '*'} element={<Report />} />
|
||||
<Route path={routes.users + '*'} element={<Users />} />
|
||||
<Route
|
||||
path={'*'}
|
||||
|
|
|
|||
|
|
@ -28,5 +28,6 @@
|
|||
"documents": "documents",
|
||||
"checklist": "checklist",
|
||||
"onSiteChecklist": "onsiteChecklist",
|
||||
"tickets": "/tickets/"
|
||||
"tickets": "/tickets/",
|
||||
"alarmReport": "/alarm_report/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,719 @@
|
|||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Container,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
OutlinedInput,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import axiosConfig from '../../../Resources/axiosConfig';
|
||||
import routes from '../../../Resources/routes.json';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TokenContext } from '../../../contexts/tokenContext';
|
||||
|
||||
interface AlarmReportRow {
|
||||
id: number;
|
||||
installationId: number;
|
||||
installationName: string;
|
||||
product: number;
|
||||
device: number;
|
||||
severity: 'error' | 'warning';
|
||||
description: string;
|
||||
date: string; // yyyy-MM-dd
|
||||
time: string; // HH:mm:ss
|
||||
deviceCreatedTheMessage: string;
|
||||
seen: boolean;
|
||||
}
|
||||
|
||||
interface AlarmGroup {
|
||||
key: string;
|
||||
installationId: number;
|
||||
installationName: string;
|
||||
product: number;
|
||||
device: number;
|
||||
severity: 'error' | 'warning';
|
||||
description: string;
|
||||
count: number;
|
||||
firstSeen: string; // "yyyy-MM-dd HH:mm:ss"
|
||||
lastSeen: string;
|
||||
events: AlarmReportRow[];
|
||||
}
|
||||
|
||||
// Product / device names are brand names — not translated (see i18n convention).
|
||||
const PRODUCT_LABELS: Record<number, string> = {
|
||||
0: 'Salimax',
|
||||
1: 'Salidomo',
|
||||
2: 'Sodistore Home',
|
||||
3: 'Sodistore Max',
|
||||
4: 'Sodistore Grid',
|
||||
5: 'Sodistore Pro'
|
||||
};
|
||||
const DEVICE_LABELS: Record<number, string> = {
|
||||
1: 'Cerbo',
|
||||
2: 'Venus',
|
||||
3: 'Growatt',
|
||||
4: 'Sinexcel'
|
||||
};
|
||||
|
||||
const productLabel = (p: number) => PRODUCT_LABELS[p] ?? `Product ${p}`;
|
||||
const deviceLabel = (d: number) => DEVICE_LABELS[d] ?? `Device ${d}`;
|
||||
|
||||
type RangePreset = 'all' | 'today' | 'last7' | 'thisMonth' | 'lastMonth' | 'custom';
|
||||
type SeverityFilter = 'both' | 'error' | 'warning';
|
||||
type ViewMode = 'grouped' | 'raw';
|
||||
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
||||
const toIsoDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
/**
|
||||
* Resolve a preset into an inclusive [from, to] yyyy-MM-dd window, or null for "all".
|
||||
* NOTE: uses the viewer's local timezone. Alarm dates are stamped in Swiss time; an
|
||||
* admin viewing from a far-off timezone near midnight could see a day-boundary skew.
|
||||
* Acceptable for v1 (almost all admins are in CH); revisit with Europe/Zurich math if needed.
|
||||
*/
|
||||
const resolveRange = (
|
||||
preset: RangePreset,
|
||||
customFrom: string,
|
||||
customTo: string
|
||||
): { from: string | null; to: string | null } => {
|
||||
const now = new Date();
|
||||
const today = toIsoDate(now);
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { from: today, to: today };
|
||||
case 'last7': {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { from: toIsoDate(d), to: today };
|
||||
}
|
||||
case 'thisMonth': {
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { from: toIsoDate(first), to: today };
|
||||
}
|
||||
case 'lastMonth': {
|
||||
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const last = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
return { from: toIsoDate(first), to: toIsoDate(last) };
|
||||
}
|
||||
case 'custom':
|
||||
return { from: customFrom || null, to: customTo || null };
|
||||
case 'all':
|
||||
default:
|
||||
return { from: null, to: null };
|
||||
}
|
||||
};
|
||||
|
||||
function Report() {
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { removeToken } = useContext(TokenContext);
|
||||
|
||||
const [rows, setRows] = useState<AlarmReportRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
const [severity, setSeverity] = useState<SeverityFilter>('both');
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
const [selectedDevices, setSelectedDevices] = useState<number[]>([]);
|
||||
const [installationFilter, setInstallationFilter] = useState<number | 'all'>('all');
|
||||
const [rangePreset, setRangePreset] = useState<RangePreset>('all');
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
/** "AbnormalGridVoltage" → "Abnormal Grid Voltage", then i18n lookup. */
|
||||
const splitCamelCase = (s: string) =>
|
||||
s.replace(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' ').trim();
|
||||
const alarmDisplayName = (description: string) =>
|
||||
intl.formatMessage({
|
||||
id: `alarm_${description}`,
|
||||
defaultMessage: splitCamelCase(description)
|
||||
});
|
||||
|
||||
const loadReport = () => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
axiosConfig
|
||||
.get('/GetAlarmReport?severity=both')
|
||||
.then((res: AxiosResponse<AlarmReportRow[]>) => {
|
||||
setRows(res.data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
if (err.response && err.response.status === 401) {
|
||||
removeToken();
|
||||
navigate(routes.login);
|
||||
return;
|
||||
}
|
||||
setLoadError(true);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadReport();
|
||||
}, []);
|
||||
|
||||
// Filter option lists derived from the data itself.
|
||||
const availableProducts = useMemo(
|
||||
() => Array.from(new Set(rows.map((r) => r.product))).sort((a, b) => a - b),
|
||||
[rows]
|
||||
);
|
||||
const availableDevices = useMemo(
|
||||
() => Array.from(new Set(rows.map((r) => r.device))).sort((a, b) => a - b),
|
||||
[rows]
|
||||
);
|
||||
const availableInstallations = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
rows.forEach((r) => map.set(r.installationId, r.installationName));
|
||||
return Array.from(map.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [rows]);
|
||||
|
||||
const range = resolveRange(rangePreset, customFrom, customTo);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (severity !== 'both' && r.severity !== severity) return false;
|
||||
if (selectedProducts.length && !selectedProducts.includes(r.product)) return false;
|
||||
if (selectedDevices.length && !selectedDevices.includes(r.device)) return false;
|
||||
if (installationFilter !== 'all' && r.installationId !== installationFilter) return false;
|
||||
if (range.from && r.date < range.from) return false;
|
||||
if (range.to && r.date > range.to) return false;
|
||||
if (term) {
|
||||
const hay = `${r.installationName} ${alarmDisplayName(r.description)} ${r.deviceCreatedTheMessage}`.toLowerCase();
|
||||
if (!hay.includes(term)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, severity, selectedProducts, selectedDevices, installationFilter, range.from, range.to, search]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, AlarmGroup>();
|
||||
filteredRows.forEach((r) => {
|
||||
const key = `${r.installationId}|${r.device}|${r.description}|${r.severity}`;
|
||||
const stamp = `${r.date} ${r.time}`;
|
||||
const existing = map.get(key);
|
||||
if (!existing) {
|
||||
map.set(key, {
|
||||
key,
|
||||
installationId: r.installationId,
|
||||
installationName: r.installationName,
|
||||
product: r.product,
|
||||
device: r.device,
|
||||
severity: r.severity,
|
||||
description: r.description,
|
||||
count: 1,
|
||||
firstSeen: stamp,
|
||||
lastSeen: stamp,
|
||||
events: [r]
|
||||
});
|
||||
} else {
|
||||
existing.count += 1;
|
||||
existing.events.push(r);
|
||||
if (stamp < existing.firstSeen) existing.firstSeen = stamp;
|
||||
if (stamp > existing.lastSeen) existing.lastSeen = stamp;
|
||||
}
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
|
||||
}, [filteredRows]);
|
||||
|
||||
const rawSorted = useMemo(
|
||||
() =>
|
||||
[...filteredRows].sort((a, b) =>
|
||||
`${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
|
||||
),
|
||||
[filteredRows]
|
||||
);
|
||||
|
||||
const toggleExpand = (key: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const severityIcon = (sev: 'error' | 'warning') =>
|
||||
sev === 'error' ? (
|
||||
<ErrorIcon color="error" />
|
||||
) : (
|
||||
<WarningIcon color="warning" />
|
||||
);
|
||||
|
||||
const exportCsv = () => {
|
||||
const header = [
|
||||
'Installation',
|
||||
'Product',
|
||||
'Device',
|
||||
'Severity',
|
||||
'Alarm',
|
||||
'Count',
|
||||
'First seen (CH)',
|
||||
'Last seen (CH)'
|
||||
];
|
||||
const lines =
|
||||
viewMode === 'grouped'
|
||||
? groups.map((g) => [
|
||||
g.installationName,
|
||||
productLabel(g.product),
|
||||
deviceLabel(g.device),
|
||||
g.severity,
|
||||
alarmDisplayName(g.description),
|
||||
String(g.count),
|
||||
g.firstSeen,
|
||||
g.lastSeen
|
||||
])
|
||||
: rawSorted.map((r) => [
|
||||
r.installationName,
|
||||
productLabel(r.product),
|
||||
deviceLabel(r.device),
|
||||
r.severity,
|
||||
alarmDisplayName(r.description),
|
||||
'1',
|
||||
`${r.date} ${r.time}`,
|
||||
`${r.date} ${r.time}`
|
||||
]);
|
||||
// Neutralize CSV formula injection (installation names are user-editable):
|
||||
// a leading =,+,-,@ would be executed as a formula by Excel/LibreOffice.
|
||||
const sanitize = (v: string) => (/^[=+\-@]/.test(v) ? `'${v}` : v);
|
||||
const escape = (v: string) => `"${sanitize(v).replace(/"/g, '""')}"`;
|
||||
const csv = [header, ...lines].map((row) => row.map(escape).join(',')).join('\r\n');
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `alarm_report_${toIsoDate(new Date())}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const renderMultiSelect = (
|
||||
labelId: string,
|
||||
label: React.ReactNode,
|
||||
values: number[],
|
||||
options: number[],
|
||||
optionLabel: (n: number) => string,
|
||||
onChange: (next: number[]) => void
|
||||
) => (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id={labelId}>{label}</InputLabel>
|
||||
<Select
|
||||
labelId={labelId}
|
||||
multiple
|
||||
value={values}
|
||||
input={<OutlinedInput label={label} />}
|
||||
renderValue={(selected) =>
|
||||
(selected as number[]).map(optionLabel).join(', ')
|
||||
}
|
||||
onChange={(e) => onChange(e.target.value as number[])}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
<Checkbox checked={values.includes(opt)} />
|
||||
<ListItemText primary={optionLabel(opt)} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3, mb: 4 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1} flexWrap="wrap">
|
||||
<Typography variant="h3">
|
||||
<FormattedMessage id="alarmReportTitle" defaultMessage="Alarm Report" />
|
||||
</Typography>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={loadReport}
|
||||
disabled={loading}
|
||||
>
|
||||
<FormattedMessage id="refresh" defaultMessage="Refresh" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={exportCsv}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<FormattedMessage id="exportCsv" defaultMessage="Export CSV" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
<FormattedMessage
|
||||
id="alarmReportRetentionNote"
|
||||
defaultMessage="Times shown in Swiss time (CET/CEST). Showing currently retained alarms — up to the last 100 events per installation."
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ p: 2, mb: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="severity-label">
|
||||
<FormattedMessage id="filterSeverity" defaultMessage="Severity" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="severity-label"
|
||||
value={severity}
|
||||
label={intl.formatMessage({ id: 'filterSeverity', defaultMessage: 'Severity' })}
|
||||
onChange={(e) => setSeverity(e.target.value as SeverityFilter)}
|
||||
>
|
||||
<MenuItem value="both">
|
||||
<FormattedMessage id="severityBoth" defaultMessage="Errors & Warnings" />
|
||||
</MenuItem>
|
||||
<MenuItem value="error">
|
||||
<FormattedMessage id="severityErrors" defaultMessage="Errors only" />
|
||||
</MenuItem>
|
||||
<MenuItem value="warning">
|
||||
<FormattedMessage id="severityWarnings" defaultMessage="Warnings only" />
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{renderMultiSelect(
|
||||
'product-label',
|
||||
<FormattedMessage id="filterProduct" defaultMessage="Product" />,
|
||||
selectedProducts,
|
||||
availableProducts,
|
||||
productLabel,
|
||||
setSelectedProducts
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{renderMultiSelect(
|
||||
'device-label',
|
||||
<FormattedMessage id="filterDevice" defaultMessage="Device" />,
|
||||
selectedDevices,
|
||||
availableDevices,
|
||||
deviceLabel,
|
||||
setSelectedDevices
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="installation-label">
|
||||
<FormattedMessage id="filterInstallation" defaultMessage="Installation" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="installation-label"
|
||||
value={installationFilter}
|
||||
label={intl.formatMessage({ id: 'filterInstallation', defaultMessage: 'Installation' })}
|
||||
onChange={(e) =>
|
||||
setInstallationFilter(
|
||||
e.target.value === 'all' ? 'all' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="all">
|
||||
<FormattedMessage id="filterAllInstallations" defaultMessage="All installations" />
|
||||
</MenuItem>
|
||||
{availableInstallations.map((i) => (
|
||||
<MenuItem key={i.id} value={i.id}>
|
||||
{i.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="range-label">
|
||||
<FormattedMessage id="filterTimeRange" defaultMessage="Time range" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="range-label"
|
||||
value={rangePreset}
|
||||
label={intl.formatMessage({ id: 'filterTimeRange', defaultMessage: 'Time range' })}
|
||||
onChange={(e) => setRangePreset(e.target.value as RangePreset)}
|
||||
>
|
||||
<MenuItem value="all">
|
||||
<FormattedMessage id="rangeAll" defaultMessage="All retained" />
|
||||
</MenuItem>
|
||||
<MenuItem value="today">
|
||||
<FormattedMessage id="rangeToday" defaultMessage="Today" />
|
||||
</MenuItem>
|
||||
<MenuItem value="last7">
|
||||
<FormattedMessage id="rangeLast7" defaultMessage="Past 7 days" />
|
||||
</MenuItem>
|
||||
<MenuItem value="thisMonth">
|
||||
<FormattedMessage id="rangeThisMonth" defaultMessage="This month" />
|
||||
</MenuItem>
|
||||
<MenuItem value="lastMonth">
|
||||
<FormattedMessage id="rangeLastMonth" defaultMessage="Last month" />
|
||||
</MenuItem>
|
||||
<MenuItem value="custom">
|
||||
<FormattedMessage id="rangeCustom" defaultMessage="Custom…" />
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{rangePreset === 'custom' && (
|
||||
<>
|
||||
<Grid item xs={6} sm={3} md={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
type="date"
|
||||
label={intl.formatMessage({ id: 'rangeFrom', defaultMessage: 'From' })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3} md={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
type="date"
|
||||
label={intl.formatMessage({ id: 'rangeTo', defaultMessage: 'To' })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
label={intl.formatMessage({ id: 'filterSearch', defaultMessage: 'Search' })}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="view-label">
|
||||
<FormattedMessage id="filterView" defaultMessage="View" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="view-label"
|
||||
value={viewMode}
|
||||
label={intl.formatMessage({ id: 'filterView', defaultMessage: 'View' })}
|
||||
onChange={(e) => setViewMode(e.target.value as ViewMode)}
|
||||
>
|
||||
<MenuItem value="grouped">
|
||||
<FormattedMessage id="viewGrouped" defaultMessage="Grouped" />
|
||||
</MenuItem>
|
||||
<MenuItem value="raw">
|
||||
<FormattedMessage id="viewRaw" defaultMessage="Raw events" />
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : loadError ? (
|
||||
<Typography color="error">
|
||||
<FormattedMessage
|
||||
id="alarmReportLoadError"
|
||||
defaultMessage="Failed to load the alarm report."
|
||||
/>
|
||||
</Typography>
|
||||
) : (
|
||||
<Card>
|
||||
<Box px={2} py={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{viewMode === 'grouped' ? (
|
||||
<FormattedMessage
|
||||
id="alarmReportGroupCount"
|
||||
defaultMessage="{groups} distinct alarms · {events} events"
|
||||
values={{ groups: groups.length, events: filteredRows.length }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="alarmReportEventCount"
|
||||
defaultMessage="{events} events"
|
||||
values={{ events: filteredRows.length }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{viewMode === 'grouped' && <TableCell />}
|
||||
<TableCell>
|
||||
<FormattedMessage id="colInstallation" defaultMessage="Installation" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="colDevice" defaultMessage="Device" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="colAlarm" defaultMessage="Alarm" />
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<FormattedMessage id="colSeverity" defaultMessage="Type" />
|
||||
</TableCell>
|
||||
{viewMode === 'grouped' ? (
|
||||
<>
|
||||
<TableCell align="center">
|
||||
<FormattedMessage id="colCount" defaultMessage="Count" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="colFirstSeen" defaultMessage="First seen (CH)" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="colLastSeen" defaultMessage="Last seen (CH)" />
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>
|
||||
<FormattedMessage id="colDate" defaultMessage="Date" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="colTime" defaultMessage="Time (CH)" />
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{viewMode === 'grouped'
|
||||
? groups.map((g) => (
|
||||
<React.Fragment key={g.key}>
|
||||
<TableRow hover>
|
||||
<TableCell padding="checkbox">
|
||||
<IconButton size="small" onClick={() => toggleExpand(g.key)}>
|
||||
{expanded.has(g.key) ? (
|
||||
<KeyboardArrowUpIcon />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{g.installationName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{productLabel(g.product)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{deviceLabel(g.device)}</TableCell>
|
||||
<TableCell>{alarmDisplayName(g.description)}</TableCell>
|
||||
<TableCell align="center">{severityIcon(g.severity)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip size="small" label={`${g.count}×`} />
|
||||
</TableCell>
|
||||
<TableCell>{g.firstSeen}</TableCell>
|
||||
<TableCell>{g.lastSeen}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0, borderBottom: 'none' }} colSpan={8}>
|
||||
<Collapse in={expanded.has(g.key)} timeout="auto" unmountOnExit>
|
||||
<Box my={1} ml={6}>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
{[...g.events]
|
||||
.sort((a, b) =>
|
||||
`${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
|
||||
)
|
||||
.map((ev) => (
|
||||
<TableRow key={ev.id}>
|
||||
<TableCell>{ev.deviceCreatedTheMessage}</TableCell>
|
||||
<TableCell>{ev.date}</TableCell>
|
||||
<TableCell>{ev.time}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
))
|
||||
: rawSorted.map((r) => (
|
||||
<TableRow hover key={r.id}>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{r.installationName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{productLabel(r.product)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{deviceLabel(r.device)}</TableCell>
|
||||
<TableCell>{alarmDisplayName(r.description)}</TableCell>
|
||||
<TableCell align="center">{severityIcon(r.severity)}</TableCell>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>{r.time}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredRows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 3 }}>
|
||||
<Typography color="text.secondary">
|
||||
<FormattedMessage
|
||||
id="alarmReportNoResults"
|
||||
defaultMessage="No alarms match the selected filters."
|
||||
/>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Report;
|
||||
|
|
@ -1,4 +1,42 @@
|
|||
{
|
||||
"alarmReportTitle": "Alarmbericht",
|
||||
"exportCsv": "CSV exportieren",
|
||||
"refresh": "Aktualisieren",
|
||||
"alarmReportRetentionNote": "Zeiten in Schweizer Zeit (MEZ/MESZ). Es werden die aktuell gespeicherten Alarme angezeigt – bis zu den letzten 100 Ereignissen pro Anlage.",
|
||||
"filterSeverity": "Schweregrad",
|
||||
"severityBoth": "Fehler & Warnungen",
|
||||
"severityErrors": "Nur Fehler",
|
||||
"severityWarnings": "Nur Warnungen",
|
||||
"filterProduct": "Produkt",
|
||||
"filterDevice": "Gerät",
|
||||
"filterInstallation": "Anlage",
|
||||
"filterAllInstallations": "Alle Anlagen",
|
||||
"filterTimeRange": "Zeitraum",
|
||||
"rangeAll": "Alle gespeicherten",
|
||||
"rangeToday": "Heute",
|
||||
"rangeLast7": "Letzte 7 Tage",
|
||||
"rangeThisMonth": "Dieser Monat",
|
||||
"rangeLastMonth": "Letzter Monat",
|
||||
"rangeCustom": "Benutzerdefiniert…",
|
||||
"rangeFrom": "Von",
|
||||
"rangeTo": "Bis",
|
||||
"filterSearch": "Suche",
|
||||
"filterView": "Ansicht",
|
||||
"viewGrouped": "Gruppiert",
|
||||
"viewRaw": "Einzelereignisse",
|
||||
"alarmReportLoadError": "Alarmbericht konnte nicht geladen werden.",
|
||||
"alarmReportGroupCount": "{groups} verschiedene Alarme · {events} Ereignisse",
|
||||
"alarmReportEventCount": "{events} Ereignisse",
|
||||
"colInstallation": "Anlage",
|
||||
"colDevice": "Gerät",
|
||||
"colAlarm": "Alarm",
|
||||
"colSeverity": "Typ",
|
||||
"colCount": "Anzahl",
|
||||
"colFirstSeen": "Zuerst gesehen (CH)",
|
||||
"colLastSeen": "Zuletzt gesehen (CH)",
|
||||
"colDate": "Datum",
|
||||
"colTime": "Zeit (CH)",
|
||||
"alarmReportNoResults": "Keine Alarme entsprechen den ausgewählten Filtern.",
|
||||
"information": "Information",
|
||||
"addNewChild": "Neues Kind hinzufügen",
|
||||
"addNewDialogButton": "Neue Dialogschaltfläche hinzufügen",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,42 @@
|
|||
{
|
||||
"alarmReportTitle": "Alarm Report",
|
||||
"exportCsv": "Export CSV",
|
||||
"refresh": "Refresh",
|
||||
"alarmReportRetentionNote": "Times shown in Swiss time (CET/CEST). Showing currently retained alarms — up to the last 100 events per installation.",
|
||||
"filterSeverity": "Severity",
|
||||
"severityBoth": "Errors & Warnings",
|
||||
"severityErrors": "Errors only",
|
||||
"severityWarnings": "Warnings only",
|
||||
"filterProduct": "Product",
|
||||
"filterDevice": "Device",
|
||||
"filterInstallation": "Installation",
|
||||
"filterAllInstallations": "All installations",
|
||||
"filterTimeRange": "Time range",
|
||||
"rangeAll": "All retained",
|
||||
"rangeToday": "Today",
|
||||
"rangeLast7": "Past 7 days",
|
||||
"rangeThisMonth": "This month",
|
||||
"rangeLastMonth": "Last month",
|
||||
"rangeCustom": "Custom…",
|
||||
"rangeFrom": "From",
|
||||
"rangeTo": "To",
|
||||
"filterSearch": "Search",
|
||||
"filterView": "View",
|
||||
"viewGrouped": "Grouped",
|
||||
"viewRaw": "Raw events",
|
||||
"alarmReportLoadError": "Failed to load the alarm report.",
|
||||
"alarmReportGroupCount": "{groups} distinct alarms · {events} events",
|
||||
"alarmReportEventCount": "{events} events",
|
||||
"colInstallation": "Installation",
|
||||
"colDevice": "Device",
|
||||
"colAlarm": "Alarm",
|
||||
"colSeverity": "Type",
|
||||
"colCount": "Count",
|
||||
"colFirstSeen": "First seen (CH)",
|
||||
"colLastSeen": "Last seen (CH)",
|
||||
"colDate": "Date",
|
||||
"colTime": "Time (CH)",
|
||||
"alarmReportNoResults": "No alarms match the selected filters.",
|
||||
"allInstallations": "All installations",
|
||||
"applyChanges": "Apply changes",
|
||||
"country": "Country",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,42 @@
|
|||
{
|
||||
"alarmReportTitle": "Rapport d'alarmes",
|
||||
"exportCsv": "Exporter CSV",
|
||||
"refresh": "Actualiser",
|
||||
"alarmReportRetentionNote": "Heures affichées en heure suisse (CET/CEST). Affiche les alarmes actuellement conservées — jusqu'aux 100 derniers événements par installation.",
|
||||
"filterSeverity": "Gravité",
|
||||
"severityBoth": "Erreurs et avertissements",
|
||||
"severityErrors": "Erreurs uniquement",
|
||||
"severityWarnings": "Avertissements uniquement",
|
||||
"filterProduct": "Produit",
|
||||
"filterDevice": "Appareil",
|
||||
"filterInstallation": "Installation",
|
||||
"filterAllInstallations": "Toutes les installations",
|
||||
"filterTimeRange": "Période",
|
||||
"rangeAll": "Tout le conservé",
|
||||
"rangeToday": "Aujourd'hui",
|
||||
"rangeLast7": "7 derniers jours",
|
||||
"rangeThisMonth": "Ce mois-ci",
|
||||
"rangeLastMonth": "Mois dernier",
|
||||
"rangeCustom": "Personnalisé…",
|
||||
"rangeFrom": "Du",
|
||||
"rangeTo": "Au",
|
||||
"filterSearch": "Recherche",
|
||||
"filterView": "Affichage",
|
||||
"viewGrouped": "Groupé",
|
||||
"viewRaw": "Événements bruts",
|
||||
"alarmReportLoadError": "Échec du chargement du rapport d'alarmes.",
|
||||
"alarmReportGroupCount": "{groups} alarmes distinctes · {events} événements",
|
||||
"alarmReportEventCount": "{events} événements",
|
||||
"colInstallation": "Installation",
|
||||
"colDevice": "Appareil",
|
||||
"colAlarm": "Alarme",
|
||||
"colSeverity": "Type",
|
||||
"colCount": "Nombre",
|
||||
"colFirstSeen": "Première occurrence (CH)",
|
||||
"colLastSeen": "Dernière occurrence (CH)",
|
||||
"colDate": "Date",
|
||||
"colTime": "Heure (CH)",
|
||||
"alarmReportNoResults": "Aucune alarme ne correspond aux filtres sélectionnés.",
|
||||
"information": "Information",
|
||||
"addUser": "Créer un utilisateur",
|
||||
"alarms": "Alarmes",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,42 @@
|
|||
{
|
||||
"alarmReportTitle": "Rapporto allarmi",
|
||||
"exportCsv": "Esporta CSV",
|
||||
"refresh": "Aggiorna",
|
||||
"alarmReportRetentionNote": "Orari mostrati in ora svizzera (CET/CEST). Vengono mostrati gli allarmi attualmente conservati — fino agli ultimi 100 eventi per installazione.",
|
||||
"filterSeverity": "Gravità",
|
||||
"severityBoth": "Errori e avvisi",
|
||||
"severityErrors": "Solo errori",
|
||||
"severityWarnings": "Solo avvisi",
|
||||
"filterProduct": "Prodotto",
|
||||
"filterDevice": "Dispositivo",
|
||||
"filterInstallation": "Installazione",
|
||||
"filterAllInstallations": "Tutte le installazioni",
|
||||
"filterTimeRange": "Intervallo di tempo",
|
||||
"rangeAll": "Tutti i conservati",
|
||||
"rangeToday": "Oggi",
|
||||
"rangeLast7": "Ultimi 7 giorni",
|
||||
"rangeThisMonth": "Questo mese",
|
||||
"rangeLastMonth": "Mese scorso",
|
||||
"rangeCustom": "Personalizzato…",
|
||||
"rangeFrom": "Da",
|
||||
"rangeTo": "A",
|
||||
"filterSearch": "Cerca",
|
||||
"filterView": "Vista",
|
||||
"viewGrouped": "Raggruppato",
|
||||
"viewRaw": "Eventi singoli",
|
||||
"alarmReportLoadError": "Impossibile caricare il rapporto allarmi.",
|
||||
"alarmReportGroupCount": "{groups} allarmi distinti · {events} eventi",
|
||||
"alarmReportEventCount": "{events} eventi",
|
||||
"colInstallation": "Installazione",
|
||||
"colDevice": "Dispositivo",
|
||||
"colAlarm": "Allarme",
|
||||
"colSeverity": "Tipo",
|
||||
"colCount": "Conteggio",
|
||||
"colFirstSeen": "Primo rilevamento (CH)",
|
||||
"colLastSeen": "Ultimo rilevamento (CH)",
|
||||
"colDate": "Data",
|
||||
"colTime": "Ora (CH)",
|
||||
"alarmReportNoResults": "Nessun allarme corrisponde ai filtri selezionati.",
|
||||
"allInstallations": "Tutte le installazioni",
|
||||
"applyChanges": "Applica modifiche",
|
||||
"country": "Paese",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { SidebarContext } from 'src/contexts/SidebarContext';
|
|||
import BrightnessLowTwoToneIcon from '@mui/icons-material/BrightnessLowTwoTone';
|
||||
import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone';
|
||||
import ConfirmationNumberTwoToneIcon from '@mui/icons-material/ConfirmationNumberTwoTone';
|
||||
import AssessmentTwoToneIcon from '@mui/icons-material/AssessmentTwoTone';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { UserContext } from '../../../../contexts/userContext';
|
||||
import { UserType } from '../../../../interfaces/UserTypes';
|
||||
|
|
@ -347,6 +348,19 @@ function SidebarMenu() {
|
|||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/alarm_report"
|
||||
startIcon={<AssessmentTwoToneIcon />}
|
||||
>
|
||||
<FormattedMessage id="alarmReportTitle" defaultMessage="Alarm Report" />
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
</SubMenuWrapper>
|
||||
</List>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue