This commit is contained in:
jhartworks
2026-06-23 14:48:36 +02:00
parent a445fef521
commit f0cb94ce4e
8 changed files with 796 additions and 81 deletions
+4 -4
View File
@@ -1,13 +1,13 @@
<!doctype html>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SE Trendportal</title>
<title>SE-LTD</title>
<link rel="icon" type="image/png" href="/logo.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
</html>
+371 -21
View File
@@ -463,12 +463,16 @@ export default function App() {
const [trendResult, setTrendResult] = useState(null);
const [latestValues, setLatestValues] = useState([]);
const [selections, setSelections] = useState([]);
const [selectedSelectionId, setSelectedSelectionId] = useState("");
const [selectionDraft, setSelectionDraft] = useState(defaultSelectionDraft);
const [dashboard, setDashboard] = useState({ widgets: [] });
const [dashboardData, setDashboardData] = useState({});
const [widgetDraft, setWidgetDraft] = useState(defaultWidgetDraft);
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [rolePermissions, setRolePermissions] = useState(PERMISSION_OPTIONS);
const [userDraft, setUserDraft] = useState({ name: "", username: "", password: "", role: "viewer" });
const [roleDraft, setRoleDraft] = useState({ name: "", label: "", permissions: ["view_dashboard", "view_trends"] });
const [themeMode, setThemeMode] = useState("system");
const [csvDelimiter, setCsvDelimiter] = useState(";");
const [sidebarPinned, setSidebarPinned] = useState(true);
@@ -484,15 +488,24 @@ export default function App() {
const [selectedSourceId, setSelectedSourceId] = useState("");
const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
const [manualDatapointDraft, setManualDatapointDraft] = useState({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
const [debugSnapshot, setDebugSnapshot] = useState(null);
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
const canEditAliases = user?.role === "admin" || user?.role === "technician";
const canManageUsers = user?.role === "admin";
const canEditAliases = hasPermission(user, "edit_aliases");
const canManageUsers = hasPermission(user, "manage_users");
const canManageRoles = hasPermission(user, "manage_roles");
const canViewDebug = hasPermission(user, "view_debug");
const sidebarCollapsed = !sidebarPinned && !sidebarHover;
const currentView = views.find((view) => view.id === activeView) || views[0];
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
const roleOptions = roles.length ? roles : [
{ name: "viewer", label: "Viewer" },
{ name: "technician", label: "Techniker" },
{ name: "admin", label: "Admin" },
];
const currentSource = useMemo(() => collectorSources.find((source) => source.id === selectedSourceId) || null, [collectorSources, selectedSourceId]);
const collectorSourcePoints = useMemo(() => (
selectedSourceId
@@ -677,7 +690,7 @@ export default function App() {
}, [dashboard, token]);
useEffect(() => {
if (!token || user?.role !== "admin") {
if (!token || !canManageUsers) {
setUsers([]);
return;
}
@@ -685,10 +698,24 @@ export default function App() {
api.getUsers(token)
.then((result) => setUsers(result.users))
.catch((error) => setBootError(error.message));
}, [token, user]);
}, [canManageUsers, token]);
useEffect(() => {
if (!token || !canEditAliases) {
if (!token || !canManageRoles) {
setRoles([]);
return;
}
api.getRoles(token)
.then((result) => {
setRoles(result.roles || []);
setRolePermissions(result.permissions || PERMISSION_OPTIONS);
})
.catch((error) => setBootError(error.message));
}, [canManageRoles, token]);
useEffect(() => {
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
setCollectorCapabilities(null);
setCollectorSources([]);
setCollectorDatapoints([]);
@@ -700,7 +727,7 @@ export default function App() {
.catch((error) => setBootError(error.message));
refreshCollectorData().catch((error) => setBootError(error.message));
}, [canEditAliases, token]);
}, [canEditAliases, canManageSources, canViewDebug, token]);
useEffect(() => {
if (!currentSource) {
@@ -719,13 +746,45 @@ export default function App() {
writeMode: currentSource.writeMode === "cov" ? "cov" : "interval",
pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60),
});
setManualDatapointDraft((current) => ({
...current,
dataType: currentSource.protocol === "knx-ip" ? "group-address" : "holding-register",
}));
}, [currentSource]);
useEffect(() => {
if (!canManageUsers && adminSection === "users") {
setAdminSection("aliases");
return;
}
}, [adminSection, canManageUsers]);
if (!canManageRoles && adminSection === "roles") {
setAdminSection("aliases");
return;
}
if (!canViewDebug && adminSection === "debug") {
setAdminSection("aliases");
}
}, [activeView, adminSection, canAccessAdmin, canManageRoles, canManageUsers, canViewDashboard, canViewDebug, canViewTrends]);
useEffect(() => {
if (!selectedSelectionId) {
return;
}
if (!selections.some((selection) => selection.id === selectedSelectionId)) {
setSelectedSelectionId("");
}
}, [selectedSelectionId, selections]);
useEffect(() => {
if (!token || !canViewDebug || adminSection !== "debug") {
return;
}
refreshCollectorDebug().catch((error) => setBootError(error.message));
}, [adminSection, canViewDebug, token]);
const handleLogin = async (credentials) => {
setBusy(true);
@@ -771,6 +830,7 @@ export default function App() {
};
const startNewSelection = () => {
setSelectedSelectionId("");
setWorkingPoints([]);
setSelectionDraft(defaultSelectionDraft);
setTrendResult(null);
@@ -780,6 +840,7 @@ export default function App() {
};
const loadSelection = (selection) => {
setSelectedSelectionId(selection.id);
setWorkingPoints(selection.points);
setSelectionDraft({
name: selection.name,
@@ -840,6 +901,10 @@ export default function App() {
});
const refreshed = await api.getSelections(token);
setSelections(refreshed.selections);
const savedSelection = refreshed.selections.find((selection) => selection.name === (selectionDraft.name || "Neue Auswahl")) || refreshed.selections[0];
if (savedSelection) {
setSelectedSelectionId(savedSelection.id);
}
setSuccessMessage("Auswahl gespeichert.");
} catch (error) {
setBootError(error.message);
@@ -897,6 +962,11 @@ export default function App() {
setCollectorDatapoints(datapointResult.datapoints || []);
};
const refreshCollectorDebug = async () => {
const result = await api.getCollectorDebug();
setDebugSnapshot(result);
};
const updateAliasPoint = (pointIndex, changes) => {
setAliasEditor((current) => ({
...current,
@@ -1038,6 +1108,7 @@ export default function App() {
const startNewSource = () => {
setIsCreatingSource(true);
setSelectedSourceId("");
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
setSourceDraft({
...defaultSourceDraft,
port: getDefaultProtocolPort(defaultSourceDraft.protocol),
@@ -1084,6 +1155,7 @@ export default function App() {
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
const refreshed = await api.getIsps(token);
setIsps(refreshed.isps);
setIsCreatingSource(false);
@@ -1103,6 +1175,7 @@ export default function App() {
try {
await api.deleteCollectorSource(selectedSourceId);
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setIsCreatingSource(false);
setSelectedSourceId("");
setSourceDraft(defaultSourceDraft);
@@ -1112,6 +1185,44 @@ export default function App() {
}
};
const downloadModbusTemplate = () => {
const sample = [
"pointIndex;address;name;alias;unit;datatype;kind;scale",
"1;40001;Vorlauf;Vorlauf;°C;holding-register;analog;1",
"2;40002;Freigabe;Freigabe;;coil;digital;1",
].join("\n");
downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;");
};
const saveManualCollectorDatapoint = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle speichern oder auswählen.");
return;
}
resetFlash();
try {
await api.createCollectorDatapoint({
sourceId: activeSourceId,
protocol: sourceDraft.protocol,
alias: manualDatapointDraft.alias,
name: manualDatapointDraft.alias,
address: manualDatapointDraft.address,
pointIndex: manualDatapointDraft.pointIndex,
unit: manualDatapointDraft.unit || "°C",
dataType: manualDatapointDraft.dataType,
kind: manualDatapointDraft.kind,
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: sourceDraft.protocol === "knx-ip" ? "group-address" : "holding-register", kind: "analog" });
setSuccessMessage("Datenpunkt angelegt.");
} catch (error) {
setBootError(error.message);
}
};
const importCollectorFile = async (event, type) => {
const file = event.target.files?.[0];
if (!file) {
@@ -1140,6 +1251,7 @@ export default function App() {
}
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage(type === "modbus" ? "Modbus-CSV importiert." : "KNX-XML importiert.");
} catch (error) {
setBootError(error.message);
@@ -1156,6 +1268,7 @@ export default function App() {
port: sourceDraft.port || "4840",
});
setScanResults((current) => ({ ...current, opcua: result }));
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("OPC-UA-Scan abgeschlossen.");
} catch (error) {
setBootError(error.message);
@@ -1175,6 +1288,7 @@ export default function App() {
deviceId: sourceDraft.deviceId,
});
setScanResults((current) => ({ ...current, bacnet: result }));
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("BACnet-Scan abgeschlossen.");
} catch (error) {
setBootError(error.message);
@@ -1184,8 +1298,12 @@ export default function App() {
const createUser = async () => {
resetFlash();
try {
await api.createUser(token, userDraft);
setUserDraft({ name: "", username: "", password: "", role: "viewer" });
await api.createUser(token, {
...userDraft,
name: userDraft.name.trim(),
username: userDraft.username.trim(),
});
setUserDraft({ name: "", username: "", password: "", role: roleOptions.find((role) => role.name === "viewer")?.name || "viewer" });
const refreshed = await api.getUsers(token);
setUsers(refreshed.users);
setSuccessMessage("Benutzer angelegt.");
@@ -1194,6 +1312,35 @@ export default function App() {
}
};
const saveRoleDefinition = async () => {
resetFlash();
try {
const payload = {
name: roleDraft.name.trim(),
label: roleDraft.label.trim() || roleDraft.name.trim(),
permissions: roleDraft.permissions,
};
if (!payload.name) {
setBootError("Bitte zuerst einen Rollennamen vergeben.");
return;
}
const existing = roles.find((role) => role.name === payload.name);
if (existing) {
await api.updateRole(token, payload.name, payload);
} else {
await api.createRole(token, payload);
}
const refreshed = await api.getRoles(token);
setRoles(refreshed.roles || []);
setRolePermissions(refreshed.permissions || PERMISSION_OPTIONS);
setSuccessMessage("Rolle gespeichert.");
} catch (error) {
setBootError(error.message);
}
};
const updateUser = async (targetUser, changes) => {
resetFlash();
try {
@@ -1262,7 +1409,18 @@ export default function App() {
</div>
<nav className="nav-list">
{views.filter((view) => view.id !== "admin" || canEditAliases).map((view) => (
{views.filter((view) => {
if (view.id === "dashboard") {
return canViewDashboard;
}
if (view.id === "trends") {
return canViewTrends;
}
if (view.id === "admin") {
return canAccessAdmin;
}
return true;
}).map((view) => (
<button
key={view.id}
type="button"
@@ -1515,6 +1673,26 @@ export default function App() {
<button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
</div>
<label>
<span>Gespeicherte Auswahl</span>
<select
value={selectedSelectionId}
onChange={(event) => {
if (!event.target.value) {
startNewSelection();
return;
}
const selection = selections.find((item) => item.id === event.target.value);
if (selection) {
loadSelection(selection);
}
}}
>
<option value="">Neue Auswahl</option>
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
</select>
</label>
<div className="form-grid compact-forms">
<label>
<span>Name</span>
@@ -1574,7 +1752,18 @@ export default function App() {
{activeView === "admin" ? (
<section className="admin-section">
<div className="admin-subnav">
{adminSections.filter((section) => section.id !== "users" || canManageUsers).map((section) => (
{adminSections.filter((section) => {
if (section.id === "users") {
return canManageUsers;
}
if (section.id === "roles") {
return canManageRoles;
}
if (section.id === "debug") {
return canViewDebug;
}
return true;
}).map((section) => (
<button
key={section.id}
type="button"
@@ -1586,7 +1775,7 @@ export default function App() {
))}
</div>
{adminSection === "aliases" ? (
{adminSection === "aliases" && canEditAliases ? (
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
@@ -1652,7 +1841,7 @@ export default function App() {
</div>
) : null}
{adminSection === "sources" ? (
{adminSection === "sources" && (canManageSources || canEditAliases) ? (
<div className="panel slim-panel">
<div className="panel-header compact-header">
<div>
@@ -1777,6 +1966,7 @@ export default function App() {
<span>Modbus CSV</span>
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
</label>
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
<label className="ghost-button compact-button file-button">
<span>KNX XML</span>
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
@@ -1785,6 +1975,42 @@ export default function App() {
<small className="muted">Zuletzt ausgewählte Quelle: {currentSource?.name || "noch keine"}</small>
</div>
{(sourceDraft.protocol === "modbus-tcp" || sourceDraft.protocol === "knx-ip") ? (
<div className="protocol-block">
<strong>Einzelner Datenpunkt</strong>
<div className="form-grid compact-forms">
<label>
<span>Alias</span>
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
</label>
<label>
<span>{sourceDraft.protocol === "knx-ip" ? "Gruppenadresse" : "Adresse / Register"}</span>
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="200" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
</label>
<label>
<span>Datenart</span>
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
</label>
<label>
<span>Einheit</span>
<UnitField value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
</label>
</div>
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>Datenpunkt anlegen</button>
</div>
) : null}
<div className="protocol-block">
<strong>OPC UA</strong>
<div className="button-row wrap-tight">
@@ -1834,7 +2060,7 @@ export default function App() {
<div className="panel-header compact-header nested-gap">
<div>
<h3>Benutzer</h3>
<p>Login erfolgt über Username statt E-Mail. Rollen können nachträglich geändert werden.</p>
<p>Login erfolgt über Username statt E-Mail. Die eigene Rolle bleibt gesperrt.</p>
</div>
</div>
<div className="form-grid compact-forms">
@@ -1853,9 +2079,7 @@ export default function App() {
<label>
<span>Rolle</span>
<select value={userDraft.role} onChange={(event) => setUserDraft((current) => ({ ...current, role: event.target.value }))}>
<option value="viewer">viewer</option>
<option value="technician">technician</option>
<option value="admin">admin</option>
{roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
</select>
</label>
</div>
@@ -1868,11 +2092,10 @@ export default function App() {
<strong>{listedUser.name}</strong>
<span>@{listedUser.username}</span>
</div>
<select value={listedUser.role} onChange={(event) => updateUser(listedUser, { role: event.target.value })}>
<option value="viewer">viewer</option>
<option value="technician">technician</option>
<option value="admin">admin</option>
<select value={listedUser.role} disabled={listedUser.id === user?.id} onChange={(event) => updateUser(listedUser, { role: event.target.value })}>
{roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
</select>
{listedUser.id === user?.id ? <small className="muted">Eigene Rolle gesperrt</small> : null}
<label className="checkbox-field compact-check">
<input type="checkbox" checked={listedUser.active} onChange={(event) => updateUser(listedUser, { active: event.target.checked })} />
<span>aktiv</span>
@@ -1882,6 +2105,111 @@ export default function App() {
</div>
</div>
) : null}
{adminSection === "roles" && canManageRoles ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Rollen</h3>
<p>Neue Rollen anlegen oder vorhandene Rechte gezielt anpassen.</p>
</div>
</div>
<div className="selection-list compact">
{roles.map((role) => (
<button
key={role.name}
type="button"
className={roleDraft.name === role.name ? "selection-card source-card active" : "selection-card source-card"}
onClick={() => setRoleDraft({ name: role.name, label: role.label, permissions: role.permissions || [] })}
>
<div>
<strong>{role.label}</strong>
<span>{role.name}</span>
</div>
<span>{(role.permissions || []).length} Rechte</span>
</button>
))}
</div>
<div className="form-grid compact-forms">
<label>
<span>Rollenname</span>
<input value={roleDraft.name} readOnly={Boolean(roles.find((role) => role.name === roleDraft.name)?.isSystem)} onChange={(event) => setRoleDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<label>
<span>Anzeigename</span>
<input value={roleDraft.label} onChange={(event) => setRoleDraft((current) => ({ ...current, label: event.target.value }))} />
</label>
</div>
<div className="selection-list compact">
{rolePermissions.map((permission) => (
<label key={permission.id} className="checkbox-field compact-check">
<input
type="checkbox"
checked={roleDraft.permissions.includes(permission.id)}
onChange={(event) => setRoleDraft((current) => ({
...current,
permissions: event.target.checked
? current.permissions.concat(permission.id).filter((value, index, list) => list.indexOf(value) === index)
: current.permissions.filter((entry) => entry !== permission.id),
}))}
/>
<span>{permission.label}</span>
</label>
))}
</div>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => setRoleDraft({ name: "", label: "", permissions: ["view_dashboard", "view_trends"] })}>Neue Rolle</button>
<button className="primary-button compact-button" type="button" onClick={saveRoleDefinition}>Rolle speichern</button>
</div>
</div>
) : null}
{adminSection === "debug" && canViewDebug ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Treiber-Debug</h3>
<p>Status von Quellen, Datenpunkten und letzten Scans.</p>
</div>
<button className="ghost-button compact-button" type="button" onClick={refreshCollectorDebug}>Aktualisieren</button>
</div>
<div className="grid two compact-grid">
<div className="panel nested-panel">
<h3>Collector</h3>
<p>{debugSnapshot?.driverState?.note || "Noch keine Debug-Daten geladen."}</p>
<div className="selection-list compact">
<div className="selection-card source-card preview-card"><strong>Quellen</strong><span>{debugSnapshot?.sourceCount || 0}</span></div>
<div className="selection-card source-card preview-card"><strong>Datenpunkte</strong><span>{debugSnapshot?.datapointCount || 0}</span></div>
<div className="selection-card source-card preview-card"><strong>Letztes Update</strong><span>{debugSnapshot?.updatedAt ? formatTimestampLabel(debugSnapshot.updatedAt) : "-"}</span></div>
</div>
</div>
<div className="panel nested-panel">
<h3>Letzte Scans</h3>
<div className="selection-list compact">
<div className="selection-card source-card preview-card"><strong>OPC UA</strong><span>{debugSnapshot?.latestScans?.opcua?.reachable ? `${debugSnapshot.latestScans.opcua.endpoints.length} Endpunkte` : "kein Treffer"}</span></div>
<div className="selection-card source-card preview-card"><strong>BACnet</strong><span>{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Geräte</span></div>
</div>
</div>
</div>
<div className="selection-list compact source-list">
{(debugSnapshot?.sources || []).map((source) => (
<div key={source.id} className="selection-card source-card preview-card">
<div>
<strong>{source.name}</strong>
<span>{source.protocol} · {source.host || "ohne Host"}</span>
</div>
<span>{source.datapointCount} DP</span>
</div>
))}
</div>
</div>
) : null}
</section>
) : null}
@@ -1963,3 +2291,25 @@ export default function App() {
+5
View File
@@ -62,6 +62,9 @@ export const api = {
getUsers: (token) => apiRequest("/users", { token }),
createUser: (token, body) => apiRequest("/users", { method: "POST", token, body }),
updateUser: (token, id, body) => apiRequest(`/users/${id}`, { method: "PATCH", token, body }),
getRoles: (token) => apiRequest("/roles", { token }),
createRole: (token, body) => apiRequest("/roles", { method: "POST", token, body }),
updateRole: (token, name, body) => apiRequest(`/roles/${encodeURIComponent(name)}`, { method: "PATCH", token, body }),
getSelections: (token) => apiRequest("/selections", { token }),
createSelection: (token, body) => apiRequest("/selections", { method: "POST", token, body }),
updateSelection: (token, id, body) => apiRequest(`/selections/${id}`, { method: "PUT", token, body }),
@@ -79,6 +82,8 @@ export const api = {
createCollectorSource: (body) => collectorRequest("/sources", { method: "POST", body }),
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }),
createCollectorDatapoint: (body) => collectorRequest("/datapoints/manual", { method: "POST", body }),
getCollectorDebug: () => collectorRequest("/debug"),
importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
+46 -25
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { jsPDF } from "jspdf";
import * as XLSX from "xlsx";
import {
@@ -32,6 +32,31 @@ function buildRandomColor(index) {
return `hsl(${hue} ${saturation}% ${lightness}%)`;
}
function getAnalogDomain(rows, visibleSeries) {
const values = [];
rows.forEach((row) => {
visibleSeries.filter((item) => item.kind !== "digital").forEach((item) => {
const value = row[item.key];
if (Number.isFinite(value)) {
values.push(value);
}
});
});
if (!values.length) {
return [0, 1];
}
const min = Math.min(...values);
const max = Math.max(...values);
if (min === max) {
return [min - 1, max + 1];
}
const padding = (max - min) * 0.08;
return [min - padding, max + padding];
}
export default function TrendChart({
result,
title = "Trendansicht",
@@ -66,6 +91,8 @@ export default function TrendChart({
})), [result]);
const visibleSeries = selectedKeys.length ? series.filter((item) => selectedKeys.includes(item.key)) : series;
const analogSeries = visibleSeries.filter((item) => item.kind !== "digital");
const digitalSeries = visibleSeries.filter((item) => item.kind === "digital");
const baseXDomain = useMemo(() => {
if (!rows.length) {
@@ -75,29 +102,11 @@ export default function TrendChart({
}, [rows]);
const baseYDomain = useMemo(() => {
const values = [];
rows.forEach((row) => {
visibleSeries.forEach((item) => {
const value = row[item.key];
if (Number.isFinite(value)) {
values.push(value);
}
});
});
if (!values.length) {
return [0, 1];
if (analogSeries.length === 0 && digitalSeries.length > 0) {
return [0, 2];
}
const min = Math.min(...values);
const max = Math.max(...values);
if (min === max) {
return [min - 1, max + 1];
}
const padding = (max - min) * 0.08;
return [min - padding, max + padding];
}, [rows, visibleSeries]);
return getAnalogDomain(rows, visibleSeries);
}, [analogSeries.length, digitalSeries.length, rows, visibleSeries]);
const xDomain = zoomDomain?.x || baseXDomain;
const yDomain = zoomDomain?.y || baseYDomain;
@@ -375,7 +384,18 @@ export default function TrendChart({
minTickGap={28}
stroke="var(--muted)"
/>
<YAxis stroke="var(--muted)" domain={yDomain} width={52} />
{analogSeries.length ? <YAxis yAxisId="analog" stroke="var(--muted)" domain={yDomain} width={52} /> : null}
{digitalSeries.length ? (
<YAxis
yAxisId="digital"
orientation="right"
domain={[0, 2]}
ticks={[0, 1]}
width={46}
stroke="var(--muted)"
allowDecimals={false}
/>
) : null}
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
contentStyle={{
@@ -401,7 +421,8 @@ export default function TrendChart({
return (
<Line
key={entry.key}
type="monotone"
yAxisId={entry.kind === "digital" ? "digital" : "analog"}
type={entry.kind === "digital" ? "stepAfter" : "monotone"}
dataKey={entry.key}
dot={false}
strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8}