Dashboard: Make charger table rows epandable, add status texts

This commit is contained in:
Simon Stürz 2025-12-17 10:50:14 +01:00
parent 2d6d3c982e
commit 17b7f0b9db
2 changed files with 246 additions and 10 deletions

View File

@ -46,6 +46,7 @@ class DashboardApp {
this.tokenRefreshTimer = null;
this.refreshInFlight = false;
this.chargers = new Map();
this.expandedChargers = new Set();
this.cars = new Map();
this.sessions = [];
this.activePanel = null;
@ -69,10 +70,7 @@ class DashboardApp {
{ key: 'chargingCurrent', label: 'Charging current' },
{ key: 'chargingPhases', label: 'Charging phases' },
{ key: 'currentPower', label: 'Current power' },
{ key: 'sessionEnergy', label: 'Session energy' },
{ key: 'version', label: 'Version' },
{ key: 'temperature', label: 'Temperature' },
{ key: 'digitalInputMode', label: 'Digital input' }
{ key: 'sessionEnergy', label: 'Session energy' }
];
this.translateDocument();
@ -144,6 +142,14 @@ class DashboardApp {
'chargers.columns.version': 'Version',
'chargers.columns.temperature': 'Temperature',
'chargers.columns.digitalInputMode': 'Digital input',
'chargerStatus.Init': 'Initializing',
'chargerStatus.A1': 'Charger ready',
'chargerStatus.A2': 'Charger ready',
'chargerStatus.B1': 'Car connected, autorization required',
'chargerStatus.B2': 'Car connected',
'chargerStatus.C1': 'Charging pause, car ready',
'chargerStatus.C2': 'Charging',
'chargerStatus.F': 'Error',
'sessions.history': 'History',
'sessions.title': 'Charging sessions',
@ -186,6 +192,7 @@ class DashboardApp {
'help.referenceBullet1': 'The dashboard keeps one row per charger ID and updates it with backend notifications.',
'help.referenceBullet2': 'Columns follow the order defined by <code>EvDashEngine::packCharger</code> so new properties show up automatically.',
'help.referenceBullet3': 'Branding (colours, fonts) is managed via CSS variables at the top of this file for easy overrides.',
'help.referenceBullet4': 'Select a charger row to expand additional charger details.',
'easterEgg.hiddenTreat': 'Hidden treat',
'easterEgg.title': 'Grid Dash',
@ -283,6 +290,14 @@ class DashboardApp {
'chargers.columns.version': 'Version',
'chargers.columns.temperature': 'Temperatur',
'chargers.columns.digitalInputMode': 'Digitaler Eingang',
'chargerStatus.Init': 'Initialisierung',
'chargerStatus.A1': 'Ladestation bereit',
'chargerStatus.A2': 'Ladestation bereit',
'chargerStatus.B1': 'Fahrzeug verbunden, Autorisierung erforderlich',
'chargerStatus.B2': 'Fahrzeug verbunden',
'chargerStatus.C1': 'Ladepause, Fahrzeug bereit',
'chargerStatus.C2': 'Laden',
'chargerStatus.F': 'Fehler',
'sessions.history': 'Historie',
'sessions.title': 'Ladevorgänge',
@ -325,6 +340,7 @@ class DashboardApp {
'help.referenceBullet1': 'Das Dashboard hält eine Zeile pro Ladestations-ID und aktualisiert sie über Backend-Benachrichtigungen.',
'help.referenceBullet2': 'Die Spalten folgen der Reihenfolge aus <code>EvDashEngine::packCharger</code>, sodass neue Eigenschaften automatisch erscheinen.',
'help.referenceBullet3': 'Branding (Farben, Schrift) wird über CSS-Variablen am Anfang dieser Datei gesteuert.',
'help.referenceBullet4': 'Wähle eine Ladestationszeile aus, um zusätzliche Details einzublenden.',
'easterEgg.hiddenTreat': 'Verstecktes Extra',
'easterEgg.title': 'Grid Dash',
@ -494,6 +510,27 @@ class DashboardApp {
this.stopEasterEggGame();
});
}
if (this.elements.chargerTableBody) {
this.elements.chargerTableBody.addEventListener('click', event => {
const targetRow = event.target ? event.target.closest('tr[data-charger-id]') : null;
if (!targetRow || !targetRow.dataset || !targetRow.dataset.chargerId)
return;
this.toggleChargerDetails(targetRow.dataset.chargerId);
});
this.elements.chargerTableBody.addEventListener('keydown', event => {
if (!event || (event.key !== 'Enter' && event.key !== ' '))
return;
const targetRow = event.target ? event.target.closest('tr[data-charger-id]') : null;
if (!targetRow || !targetRow.dataset || !targetRow.dataset.chargerId)
return;
event.preventDefault();
this.toggleChargerDetails(targetRow.dataset.chargerId);
});
}
}
initializePanelNavigation() {
@ -1125,8 +1162,12 @@ class DashboardApp {
let row = this.findChargerRow(key);
if (!row || forceCreate) {
if (row && row.parentElement)
if (row && row.parentElement) {
const detailsRow = this.findChargerDetailsRow(key);
if (detailsRow && detailsRow.parentElement)
detailsRow.parentElement.removeChild(detailsRow);
row.parentElement.removeChild(row);
}
row = this.buildChargerRow(charger);
this.elements.chargerTableBody.appendChild(row);
} else {
@ -1140,12 +1181,17 @@ class DashboardApp {
});
}
this.syncChargerDetailsVisibility(key);
this.toggleChargerEmptyState();
}
buildChargerRow(charger) {
const row = document.createElement('tr');
row.classList.add('charger-row');
row.dataset.chargerId = this.getChargerKey(charger) || '';
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.setAttribute('aria-expanded', 'false');
this.chargerColumns.forEach(column => {
if (column.hidden)
return;
@ -1157,6 +1203,140 @@ class DashboardApp {
return row;
}
toggleChargerDetails(chargerId) {
if (!chargerId)
return;
const key = this.getChargerKey(chargerId);
if (!key)
return;
if (this.expandedChargers.has(key))
this.collapseChargerDetails(key);
else
this.expandChargerDetails(key);
}
expandChargerDetails(chargerId) {
if (!chargerId)
return;
const key = this.getChargerKey(chargerId);
if (!key)
return;
this.expandedChargers.add(key);
this.syncChargerDetailsVisibility(key);
}
collapseChargerDetails(chargerId) {
if (!chargerId)
return;
const key = this.getChargerKey(chargerId);
if (!key)
return;
this.expandedChargers.delete(key);
this.syncChargerDetailsVisibility(key);
}
syncChargerDetailsVisibility(chargerId) {
if (!chargerId || !this.elements.chargerTableBody)
return;
const key = this.getChargerKey(chargerId);
if (!key)
return;
const isExpanded = this.expandedChargers.has(key);
const row = this.findChargerRow(key);
if (row) {
row.classList.toggle('is-expanded', isExpanded);
row.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
}
const detailsRow = this.findChargerDetailsRow(key);
if (!isExpanded) {
if (detailsRow && detailsRow.parentElement)
detailsRow.parentElement.removeChild(detailsRow);
return;
}
if (!row)
return;
const ensured = detailsRow || this.buildChargerDetailsRow(key);
if (ensured && ensured !== detailsRow) {
this.elements.chargerTableBody.insertBefore(ensured, row.nextSibling);
}
this.updateChargerDetailsRow(key);
}
buildChargerDetailsRow(chargerId) {
const row = document.createElement('tr');
row.classList.add('charger-details-row');
row.dataset.chargerDetailsFor = this.getChargerKey(chargerId) || '';
const cell = document.createElement('td');
cell.colSpan = this.getVisibleChargerColumnCount();
const list = document.createElement('dl');
list.className = 'charger-details-list';
cell.appendChild(list);
row.appendChild(cell);
return row;
}
updateChargerDetailsRow(chargerId) {
if (!chargerId)
return;
const key = this.getChargerKey(chargerId);
if (!key || !this.expandedChargers.has(key))
return;
const charger = this.chargers && this.chargers.get(key) ? this.chargers.get(key) : null;
const detailsRow = this.findChargerDetailsRow(key);
if (!detailsRow)
return;
const list = detailsRow.querySelector('dl.charger-details-list');
if (!list)
return;
const items = [
{ label: this.t('chargers.columns.version'), key: 'version' },
{ label: this.t('chargers.columns.temperature'), key: 'temperature' },
{ label: this.t('chargers.columns.digitalInputMode'), key: 'digitalInputMode' }
];
list.innerHTML = '';
items.forEach(item => {
const term = document.createElement('dt');
term.textContent = item.label;
const description = document.createElement('dd');
const value = charger && Object.prototype.hasOwnProperty.call(charger, item.key) ? charger[item.key] : null;
description.textContent = this.formatChargerValue(item.key, value);
list.appendChild(term);
list.appendChild(description);
});
}
findChargerDetailsRow(chargerId) {
if (!this.elements.chargerTableBody || !chargerId)
return null;
const normalizedId = typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(String(chargerId))
: String(chargerId).replace(/"/g, '\\"');
return this.elements.chargerTableBody.querySelector(`tr[data-charger-details-for="${normalizedId}"]`);
}
getVisibleChargerColumnCount() {
return this.chargerColumns.filter(column => !column.hidden).length;
}
renderCellValue(cell, key, value) {
if (!cell)
return;
@ -1184,10 +1364,14 @@ class DashboardApp {
if (!key)
return;
this.expandedChargers.delete(key);
this.chargers.delete(key);
const row = this.findChargerRow(key);
const detailsRow = this.findChargerDetailsRow(key);
if (row && row.parentElement)
row.parentElement.removeChild(row);
if (detailsRow && detailsRow.parentElement)
detailsRow.parentElement.removeChild(detailsRow);
this.toggleChargerEmptyState();
}
@ -1196,7 +1380,8 @@ class DashboardApp {
if (!this.elements.chargerTableBody)
return;
const rows = this.elements.chargerTableBody.querySelectorAll('tr[data-charger-id]');
this.expandedChargers.clear();
const rows = this.elements.chargerTableBody.querySelectorAll('tr[data-charger-id], tr[data-charger-details-for]');
rows.forEach(row => {
if (row.parentElement)
row.parentElement.removeChild(row);
@ -1341,6 +1526,23 @@ class DashboardApp {
if (value === null || value === undefined || value === '')
return '—';
if (key === 'status') {
const code = String(value).trim();
const statusKeys = {
Init: 'chargerStatus.Init',
A1: 'chargerStatus.A1',
A2: 'chargerStatus.A2',
B1: 'chargerStatus.B1',
B2: 'chargerStatus.B2',
C1: 'chargerStatus.C1',
C2: 'chargerStatus.C2',
F: 'chargerStatus.F'
};
if (code in statusKeys)
return `${code}: ${this.t(statusKeys[code])}`;
return code || '—';
}
if (key === 'energyManagerMode') {
const modes = {
0: this.t('energyManagerMode.quick'),

View File

@ -343,6 +343,42 @@
background: #f9fbfd;
}
.chargers-table tbody tr[data-charger-id] {
cursor: pointer;
}
.chargers-table tbody tr[data-charger-id]:hover,
.chargers-table tbody tr[data-charger-id].is-expanded {
background: rgba(88, 97, 107, 0.08);
}
.charger-details-row td {
border-bottom: 1px solid #e4e9f2;
padding: 0.75rem;
background: rgba(88, 97, 107, 0.04);
}
.charger-details-list {
margin: 0;
display: grid;
grid-template-columns: minmax(120px, 220px) minmax(0, 1fr);
row-gap: 0.25rem;
column-gap: 1rem;
font-size: 0.9rem;
line-height: 1.35;
}
.charger-details-list dt {
font-weight: 600;
color: #1f2a37;
}
.charger-details-list dd {
margin: 0;
color: #1f2a37;
word-break: break-word;
}
.data-table .empty-row td {
text-align: center;
font-style: italic;
@ -713,14 +749,11 @@
<th scope="col" data-i18n="chargers.columns.chargingPhases">Charging phases</th>
<th scope="col" data-i18n="chargers.columns.currentPower">Current power</th>
<th scope="col" data-i18n="chargers.columns.sessionEnergy">Session energy</th>
<th scope="col" data-i18n="chargers.columns.version">Version</th>
<th scope="col" data-i18n="chargers.columns.temperature">Temperature</th>
<th scope="col" data-i18n="chargers.columns.digitalInputMode">Digital input</th>
</tr>
</thead>
<tbody id="chargerTableBody">
<tr id="chargerEmptyRow" class="empty-row">
<td colspan="12" data-i18n="chargers.empty">No chargers loaded yet.</td>
<td colspan="9" data-i18n="chargers.empty">No chargers loaded yet.</td>
</tr>
</tbody>
</table>
@ -825,6 +858,7 @@
<li data-i18n="help.referenceBullet1">The dashboard keeps one row per charger ID and updates it with backend notifications.</li>
<li data-i18n="help.referenceBullet2" data-i18n-mode="html">Columns follow the order defined by <code>EvDashEngine::packCharger</code> so new properties show up automatically.</li>
<li data-i18n="help.referenceBullet3">Branding (colours, fonts) is managed via CSS variables at the top of this file for easy overrides.</li>
<li data-i18n="help.referenceBullet4">Select a charger row to expand additional charger details.</li>
</ul>
</article>
</section>