From 17b7f0b9dbbfdf2184cd6c2888af0a73263306fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Wed, 17 Dec 2025 10:50:14 +0100 Subject: [PATCH] Dashboard: Make charger table rows epandable, add status texts --- dashboard/app.js | 214 +++++++++++++++++++++++++++++++++++++++++-- dashboard/index.html | 42 ++++++++- 2 files changed, 246 insertions(+), 10 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index bfa089e..139aa01 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -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 EvDashEngine::packCharger 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 EvDashEngine::packCharger, 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'), diff --git a/dashboard/index.html b/dashboard/index.html index 0253469..ce5174d 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -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 @@ Charging phases Current power Session energy - Version - Temperature - Digital input - No chargers loaded yet. + No chargers loaded yet. @@ -825,6 +858,7 @@
  • The dashboard keeps one row per charger ID and updates it with backend notifications.
  • Columns follow the order defined by EvDashEngine::packCharger so new properties show up automatically.
  • Branding (colours, fonts) is managed via CSS variables at the top of this file for easy overrides.
  • +
  • Select a charger row to expand additional charger details.