Files
Enelix-EMS/services/license/changes/20261006-heat-pump-header/portal.patch
T
2026-10-06 14:35:17 +00:00

343 lines
26 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- a/stripe.mjs
+++ b/stripe.mjs
@@ -22,7 +22,7 @@
cost_meter_aux: { sku: "ENX-BILLING-AUX", name: "Wärme- oder Wasserzähler Abrechnung", unitAmount: 300, setupAmount: 0, entitlement: true }
});
-const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi"];
+const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi", "heat_pump"];
const forecastKeys = ["forecast_pv", "forecast_load", "grid_schedule"];
const licenseKeys = [...moduleKeys, ...forecastKeys];
--- a/portal-domain.mjs
+++ b/portal-domain.mjs
@@ -5,7 +5,8 @@
buffer_storage: { name: "Pufferspeicher", catalogKey: "buffer_storage", moduleId: "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}" },
ev_charger: { name: "EV-Ladestation", catalogKey: "ev_charger", moduleId: "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}", gatewayModuleId: "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}" },
consumer_single: { name: "Verbraucher einstufig", catalogKey: "consumer_single", moduleId: "{15879A4E-D0C2-4495-83DE-46E1E462591E}" },
- boiler_multi: { name: "Boiler mehrstufig", catalogKey: "boiler_multi", moduleId: "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" }
+ boiler_multi: { name: "Boiler mehrstufig", catalogKey: "boiler_multi", moduleId: "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" },
+ heat_pump: { name: "Wärmepumpe", catalogKey: "heat_pump", moduleId: "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}" }
});
export const integrationProfiles = Object.freeze({
@@ -210,6 +211,23 @@
f("Mindesteinschaltdauer", "Mindest-Einschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 }),
f("Mindestausschaltdauer", "Mindest-Ausschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 })
],
+ heat_pump: [...commonConsumerFields,
+ f("Kontaktart", "Kontaktart", "select", 0, "Kontakte", { min: 0, max: 1, options: [{ value: 0, label: "Sperre und Erhöhung" }, { value: 1, label: "SG Ready" }] }),
+ f("Kontakt1VariableID", "Kontakt 1: Sperre / SG Ready 1 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }),
+ f("Kontakt2VariableID", "Kontakt 2: Erhöhung / SG Ready 2 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }),
+ f("Kontakt1Invertiert", "Kontakt 1 invertieren", "boolean", false, "Kontakte"),
+ f("Kontakt2Invertiert", "Kontakt 2 invertieren", "boolean", false, "Kontakte"),
+ f("Nennleistung", "Elektrische Nennleistung (W)", "integer", 0, "Wärmepumpe", { min: 0 }),
+ f("Rueckmeldungsart", "Rückmeldungsart", "select", 0, "Betriebsrückmeldung", { min: 0, max: 1, options: [{ value: 0, label: "Gemessene Leistung" }, { value: 1, label: "Boolean-Betriebsstatus" }] }),
+ f("IstleistungVariableID", "Leistung oder Betriebsstatus (Variable-ID)", "integer", 0, "Betriebsrückmeldung", { min: 0 }),
+ f("Laufschwelle", "Laufschwelle bei Leistungsmessung (W)", "number", 100, "Betriebsrückmeldung", { min: 0 }),
+ f("Anlaufwartezeit", "Anlaufwartezeit (s)", "integer", 30, "Schutzzeiten", { min: 0 }),
+ f("Wiederholsperre", "Wiederholsperre nach erfolglosem Anlauf (s)", "integer", 300, "Schutzzeiten", { min: 0 }),
+ f("Mindestlaufzeit", "Mindestlaufzeit nach bestätigtem Start (s)", "integer", 1200, "Schutzzeiten", { min: 0 }),
+ f("Mindestsperrzeit", "Mindest-Sperrzeit (s)", "integer", 1200, "Schutzzeiten", { min: 0 }),
+ f("MaximaleSperrzeit", "Maximale Sperrzeit am Stück (s; 0 = aus)", "integer", 7200, "Schutzzeiten", { min: 0 }),
+ f("MaximaleSperrzeit24h", "Maximale Sperrzeit in 24 Stunden (s; 0 = aus)", "integer", 21600, "Schutzzeiten", { min: 0, max: 86400 })
+ ],
boiler_multi: [...commonConsumerFields,
f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Warmwassererwärmer"),
f("Boilerfuehler_PT1", "Boilerfühler Variable-ID", "integer", 0, "Warmwassererwärmer", { min: 0 }),
@@ -253,6 +271,12 @@
"Nennleistung", "SchaltkontaktInvertiert", "Mindestlaufzeit",
"Mindesteinschaltdauer", "Mindestausschaltdauer"
]),
+ heat_pump: new Set([
+ "Kontaktart", "Kontakt1VariableID", "Kontakt2VariableID", "Kontakt1Invertiert",
+ "Kontakt2Invertiert", "Nennleistung", "Rueckmeldungsart", "IstleistungVariableID",
+ "Laufschwelle", "Anlaufwartezeit", "Wiederholsperre", "Mindestlaufzeit",
+ "Mindestsperrzeit", "MaximaleSperrzeit", "MaximaleSperrzeit24h"
+ ]),
boiler_multi: new Set([
"Boilervolumen", "Hysterese", "LegionellenfunktionAktiv",
"LegionellenMinimalintervallTage", "LegionellenMaximalintervallTage"
@@ -455,8 +479,8 @@
while (rawItems.length < requested) rawItems.push(legacyItem(key, source, rawItems.length));
const items = rawItems.map((item, index) => {
const variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone";
- const kind = key === "battery" ? "storage" : (["buffer_storage", "consumer_single", "boiler_multi"].includes(key) ? "actuator" : null);
- const integration = kind ? normalizeIntegration(item.integration, kind) : {
+ const kind = key === "battery" ? "storage" : (["buffer_storage", "consumer_single", "boiler_multi", "heat_pump"].includes(key) ? "actuator" : null);
+ const integration = kind ? normalizeIntegration(key === "heat_pump" ? { profile: "manual" } : item.integration, kind) : {
profile: text(item.integration?.profile || "device_api", 60),
host: text(item.integration?.host, 120),
port: finite(item.integration?.port, 0, 0, 65535, true),
@@ -588,6 +612,14 @@
return result;
}
+export function licenseCoverage(requested = {}, owned = {}) {
+ const coverage = { ...owned };
+ const spareConsumers = Math.max(0, Number(owned.consumer_single || 0) - Number(requested.consumer_single || 0));
+ coverage.heat_pump = Number(owned.heat_pump || 0) + Math.min(spareConsumers,
+ Math.max(0, Number(requested.heat_pump || 0) - Number(owned.heat_pump || 0)));
+ return coverage;
+}
+
export function recommendLicenses(configuration) {
const modules = {};
for (const key of Object.keys(systemDeviceTypes)) modules[key] = configuration.devices[key].quantity;
--- a/server.mjs
+++ b/server.mjs
@@ -19,6 +19,7 @@
createActivationCode,
normalizeActivationCode,
normalizeSystemConfiguration,
+ licenseCoverage,
publicWizardCatalog,
recommendLicenses
} from "./portal-domain.mjs";
@@ -2126,8 +2127,9 @@
const setup = ownedSetupState(plantId);
const modules = {};
const setupModules = {};
+ const coverage = licenseCoverage(recommendation.modules, owned.quantities);
for (const [key, requested] of Object.entries(recommendation.modules || {})) {
- modules[key] = Math.max(0, Number(requested || 0) - Number(owned.quantities[key] || 0));
+ modules[key] = Math.max(0, Number(requested || 0) - Number(coverage[key] || 0));
setupModules[key] = Math.max(0, Number(requested || 0) - Number(setup.quantities[key] || 0));
}
let managerAction = null;
--- a/symcon-package.mjs
+++ b/symcon-package.mjs
@@ -207,6 +207,33 @@
if (!in_array($moduleID, IPS_GetModuleList(), true)) throw new RuntimeException('Erforderliches Modul fehlt: ' . $moduleID);
}
+// Validate heat-pump bindings before creating or changing any system objects.
+foreach (($config['devices']['heat_pump']['items'] ?? []) as $item) {
+ if (!in_array($moduleIDs['heat_pump'], IPS_GetModuleList(), true)) {
+ throw new RuntimeException('Das Enelix-Waermepumpenmodul fehlt.');
+ }
+ $properties = $item['properties'];
+ $contact1 = (int) $properties['Kontakt1VariableID'];
+ $contact2 = (int) $properties['Kontakt2VariableID'];
+ if ($contact1 === $contact2 || (int) $properties['Nennleistung'] <= 0) {
+ throw new RuntimeException('Waermepumpe: zwei unterschiedliche Kontakte und Nennleistung erforderlich.');
+ }
+ foreach ([$contact1, $contact2] as $contact) {
+ if (!IPS_VariableExists($contact)) throw new RuntimeException('Waermepumpe: Kontaktvariable fehlt.');
+ $variable = IPS_GetVariable($contact);
+ if ($variable['VariableType'] !== 0 || max($variable['VariableAction'], $variable['VariableCustomAction']) <= 0) {
+ throw new RuntimeException('Waermepumpe: Kontakte muessen schaltbare Boolean-Variablen sein.');
+ }
+ }
+ $feedback = (int) $properties['IstleistungVariableID'];
+ if (!IPS_VariableExists($feedback)) throw new RuntimeException('Waermepumpe: Betriebsrueckmeldung fehlt.');
+ $feedbackType = IPS_GetVariable($feedback)['VariableType'];
+ $allowedTypes = (int) $properties['Rueckmeldungsart'] === 1 ? [0] : [1, 2];
+ if (!in_array($feedbackType, $allowedTypes, true)) {
+ throw new RuntimeException('Waermepumpe: Typ der Betriebsrueckmeldung passt nicht zur Rueckmeldungsart.');
+ }
+}
+
$root = enxCategory(0, 'ENELIX_System', (string) $config['name']);
$visualisation = enxCategory($root, 'Visualisierung', 'Visualisierung');
$energyView = enxCategory($visualisation, 'Energie', 'Energie und Bilanz');
--- a/public/index.html
+++ b/public/index.html
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Enelix EMS Portal für Systeme, Lizenzen und Prognosemanager">
<title>Enelix EMS Portal</title>
- <link rel="stylesheet" href="/styles.css?v=20260929-3">
+ <link rel="stylesheet" href="/styles.css?v=20261006-heat-pump">
<link rel="stylesheet" href="/docs-portal.css?v=20261006">
</head>
<body>
@@ -65,7 +65,7 @@
<div id="portalApp" class="portal hidden">
<header class="topbar">
<a class="brand" href="#overview"><span class="brand-mark">E</span><span><strong>enelix</strong><small>EMS Portal</small></span></a>
- <div class="top-actions"><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
+ <div class="top-actions"><nav class="header-links" aria-label="Dokumentation und Preise"><a href="/docs/index.html">Dokumentation</a><a href="/docs/preise.html">Preise</a></nav><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
</header>
<div class="app-shell">
@@ -75,8 +75,6 @@
<button class="nav-item" data-view="forecast">Prognose</button>
<button class="nav-item" data-view="faults">Störüberwachung</button>
<button class="nav-item" data-view="account">Konto</button>
- <a class="nav-item public-doc-link" href="/docs/index.html">Dokumentation</a>
- <a class="nav-item public-doc-link" href="/docs/preise.html">Preise</a>
</nav>
<div class="workspace-switcher"><label>Organisation<select id="workspaceSelect"></select></label><small id="workspaceRole">Eigentümer</small></div>
</aside>
@@ -100,7 +98,8 @@
<label class="module-row" data-key="battery" data-name="Batteriespeicher" data-price="41.95" data-setup="54.55"><span><input type="checkbox" class="module-check"><strong>Batteriespeicher</strong><small>Batterie</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 41.95</span><span>CHF 54.55</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="buffer_storage" data-name="Pufferspeicher" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Pufferspeicher</strong><small>VerbraucherPufferspeicher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="ev_charger" data-name="EV-Ladestation" data-price="33.65" data-setup="43.75"><span><input type="checkbox" class="module-check"><strong>EV-Ladestation</strong><small>Stand-Alone oder Gateway</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 33.65</span><span>CHF 43.75</span><input class="qty" type="number" min="1" max="999" value="1"></label>
- <label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>z. B. Wärmepumpe</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
+ <label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>Ein schaltbarer Verbraucher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
+ <label class="module-row" data-key="heat_pump" data-name="Wärmepumpe" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Wärmepumpe</strong><small>Sperre / Erhöhung oder SG Ready</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="boiler_multi" data-name="Boiler mehrstufig" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Boiler mehrstufig</strong><small>Warmwassererwärmer</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<div class="module-header"><span>Prognoselizenz</span><span>Jahrespreis</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="forecast_pv" data-name="PV-Ertragsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>PV-Ertragsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
@@ -117,13 +116,13 @@
<form id="systemForm" class="system-configurator">
<section class="config-form wizard">
<header class="wizard-header">
- <div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 9</p><h2 id="wizardStepTitle">Anlage</h2></div>
+ <div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 10</p><h2 id="wizardStepTitle">Anlage</h2></div>
<ol class="wizard-progress" aria-label="Fortschritt">
<li class="active" data-wizard-marker="0">Anlage</li><li data-wizard-marker="1">Energiequellen</li>
<li data-wizard-marker="2">Batterie</li><li data-wizard-marker="3">Pufferspeicher</li>
<li data-wizard-marker="4">Ladestation</li><li data-wizard-marker="5">Verbraucher</li>
- <li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Abrechnung</li>
- <li data-wizard-marker="8">Prüfen</li>
+ <li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Wärmepumpe</li>
+ <li data-wizard-marker="8">Abrechnung</li><li data-wizard-marker="9">Prüfen</li>
</ol>
</header>
<section class="wizard-step active" data-wizard-step="0">
@@ -190,11 +189,16 @@
<div class="device-editor" data-device-editor="boiler_multi"></div>
</section>
<section class="wizard-step" data-wizard-step="7">
+ <p class="wizard-question">Welche Wärmepumpen sollen eingerichtet werden?</p>
+ <div class="quantity-line"><label>Anzahl<input name="heatPumpQuantity" data-device-quantity="heat_pump" type="number" min="0" max="50" value="0"></label></div>
+ <div class="device-editor" data-device-editor="heat_pump"></div>
+ </section>
+ <section class="wizard-step" data-wizard-step="8">
<p class="wizard-question">Soll im selben Symcon-System ein Verbrauchskostenreport eingerichtet werden?</p>
<label class="setup-option"><input name="costReportEnabled" type="checkbox"><span><strong>Verbrauchskostenreport hinzufügen</strong><small>Wird als eigene Instanz eingerichtet und verwendet einen separaten Lizenzcode.</small></span></label>
<div class="field-grid"><label>Stromzähler<input name="costPowerMeters" type="number" min="0" max="999" value="0"></label><label>Wärme- oder Wasserzähler<input name="costAuxMeters" type="number" min="0" max="999" value="0"></label></div>
</section>
- <section class="wizard-step" data-wizard-step="8">
+ <section class="wizard-step" data-wizard-step="9">
<p class="wizard-question">Bitte prüfe die Angaben vor dem Speichern.</p>
<div id="wizardReview" class="wizard-review"></div>
</section>
@@ -367,6 +371,6 @@
<dialog id="deletePaidDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Zweite Bestätigung</p><h2>Auch Lizenz löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning critical-warning"><strong>Achtung: Für diese Anlage besteht eine bezahlte Lizenz.</strong><p>Sollen <b id="deletePaidSystem">die Anlage</b> und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p></div><p class="form-error" id="deletePaidError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePaid" type="button">Anlage und Lizenz löschen</button></div></div></dialog>
- <script src="/app.js?v=20260929-3"></script>
+ <script src="/app.js?v=20261006-heat-pump"></script>
</body>
</html>
--- a/public/app.js
+++ b/public/app.js
@@ -7,7 +7,7 @@
manager_peak: { name: "Manager mit Peak Shaving", price: 329.05, setup: 141.80 },
manager_peak_upgrade: { name: "Upgrade auf Peak Shaving", price: 20.90, setup: 0 }
};
- var deviceLabels = { battery: "Batteriespeicher", buffer_storage: "Pufferspeicher", ev_charger: "EV-Ladestation", consumer_single: "Verbraucher einstufig", boiler_multi: "Boiler mehrstufig" };
+ var deviceLabels = { battery: "Batteriespeicher", buffer_storage: "Pufferspeicher", ev_charger: "EV-Ladestation", consumer_single: "Verbraucher einstufig", boiler_multi: "Boiler mehrstufig", heat_pump: "Wärmepumpe" };
var roleLabels = { owner: "Eigentümer", admin: "Administrator", operator: "Operator", billing: "Abrechnung", viewer: "Lesend" };
var money = new Intl.NumberFormat("de-CH", { style: "currency", currency: "CHF" });
var byId = function (id) { return document.getElementById(id); };
@@ -437,6 +437,14 @@
return ownedManager;
}
+ function licenseCoverage(requested, owned) {
+ var coverage = Object.assign({}, owned);
+ var spareConsumers = Math.max(0, Number(owned.consumer_single || 0) - Number(requested.consumer_single || 0));
+ coverage.heat_pump = Number(owned.heat_pump || 0) + Math.min(spareConsumers,
+ Math.max(0, Number(requested.heat_pump || 0) - Number(owned.heat_pump || 0)));
+ return coverage;
+ }
+
function prepareOrderEditor(configurationId) {
var plant = selectedPlant("orderPlant");
if (!plant) return;
@@ -445,6 +453,7 @@
var recommendation = plantRecommendation(plant);
var owned = paidLicenseSummary(plant.id);
var setupOwned = paidSetupSummary(plant.id);
+ var coverage = licenseCoverage(recommendation?.modules || {}, owned);
var ownedManager = paidManagerVariant(plant);
var desiredManager = recommendation?.managerVariant || plant.manager_variant || "manager_standard";
byId("managerCurrentName").textContent = ownedManager ? managerLabel(ownedManager) : managerLabel(desiredManager);
@@ -455,7 +464,7 @@
document.querySelectorAll(".module-row").forEach(function (row) {
var recommendedQuantity = recommendation ? Number(recommendation.modules[row.dataset.key] || 0) : 0;
var ownedQuantity = Number(owned[row.dataset.key] || 0);
- var licenseQuantity = Math.max(0, recommendedQuantity - ownedQuantity);
+ var licenseQuantity = Math.max(0, recommendedQuantity - Number(coverage[row.dataset.key] || 0));
var setupQuantity = configurationPurchase
? Math.max(0, recommendedQuantity - Number(setupOwned[row.dataset.key] || 0))
: 0;
@@ -1877,7 +1886,7 @@
renderPvSurfaceEditor(pvSurfaceQuantity, pvSurfaces);
updatePvTopologyVisibility(form);
renderManagerFields(configuration.manager?.properties || {});
- var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity" };
+ var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" };
Object.keys(deviceLabels).forEach(function (key) {
var source = configuration.devices[key] || {};
var quantity = Math.max(Number(source.quantity || 0), Number(owned[key] || 0));
@@ -1908,7 +1917,7 @@
function systemPayload(form) {
var values = Object.fromEntries(new FormData(form).entries());
- var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity" };
+ var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" };
var devices = {};
Object.keys(deviceLabels).forEach(function (key) {
devices[key] = { quantity: Number(values[quantityNames[key]] || 0), items: collectDeviceItems(key) };
@@ -1932,7 +1941,7 @@
};
}
- var wizardTitles = ["Anlage", "Manager, Netz und PV", "Batteriespeicher", "Pufferspeicher", "EV-Ladestation", "Einstufige Verbraucher", "Boiler", "Verbrauchskostenreport", "Angaben prüfen"];
+ var wizardTitles = ["Anlage", "Manager, Netz und PV", "Batteriespeicher", "Pufferspeicher", "EV-Ladestation", "Einstufige Verbraucher", "Boiler", "Wärmepumpe", "Verbrauchskostenreport", "Angaben prüfen"];
function updateRecommendation() {
var payload = systemPayload(byId("systemForm"));
@@ -1941,9 +1950,11 @@
var selectedDevices = Object.keys(deviceLabels).filter(function (key) { return payload.devices[key].quantity > 0; });
var configuredPlant = state.plants.find(function (plant) { return plant.id === byId("systemForm").dataset.plantId; });
var owned = configuredPlant ? paidLicenseSummary(configuredPlant.id) : {};
+ var requestedModules = Object.fromEntries(Object.entries(payload.devices).map(function (entry) { return [entry[0], entry[1].quantity]; }));
+ var coverage = licenseCoverage(requestedModules, owned);
var rows = selectedDevices.map(function (key) {
var requested = Number(payload.devices[key].quantity || 0);
- var licensed = Number(owned[key] || 0);
+ var licensed = Number(coverage[key] || 0);
var missing = Math.max(0, requested - licensed);
var licenseText = missing
? licensed + " lizenziert · " + missing + " zusätzliche " + (missing === 1 ? "Lizenz" : "Lizenzen") + " erforderlich"
--- a/public/styles.css
+++ b/public/styles.css
@@ -85,6 +85,10 @@
.portal { min-height: 100vh; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; height: 68px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); padding: 0 28px; }
.top-actions { display: flex; align-items: center; gap: 13px; }
+.header-links { display: flex; align-items: center; gap: 24px; margin-right: 16px; }
+.header-links a { color: var(--petrol); font-size: .88rem; font-weight: 700; text-decoration: none; padding: 10px 0; white-space: nowrap; }
+.header-links a:hover { text-decoration: underline; text-underline-offset: 5px; }
+.header-links a:focus-visible { outline: 2px solid var(--petrol); outline-offset: 4px; }
.environment, .status { display: inline-flex; align-items: center; border-radius: 999px; white-space: nowrap; font-size: .7rem; font-weight: 800; }
.environment { background: #edf2f1; color: #566568; padding: 5px 9px; text-transform: uppercase; letter-spacing: .07em; }
.account-button { display: flex; align-items: center; gap: 8px; border: 0; background: transparent; color: var(--ink); }
@@ -239,8 +243,8 @@
.wizard { min-height: 520px; align-content: start; border-top: 3px solid var(--petrol); background: #fff; padding: 24px; }
.wizard-header { display: grid; gap: 20px; border-bottom: 1px solid var(--line); padding-bottom: 20px; }
.wizard-header h2 { font-size: 1.45rem; }
-.wizard-progress { display: grid; grid-template-columns: repeat(9, minmax(72px, 1fr)); overflow-x: auto; margin: 0; padding: 0; list-style: none; }
-.wizard-progress li { min-width: 72px; border-top: 3px solid var(--line); padding: 8px 5px 0; color: var(--muted); font-size: .7rem; font-weight: 750; text-align: center; }
+.wizard-progress { display: grid; grid-template-columns: repeat(10, minmax(max-content, 1fr)); overflow-x: auto; margin: 0; padding: 0; list-style: none; }
+.wizard-progress li { min-width: 72px; border-top: 3px solid var(--line); padding: 8px 8px 0; color: var(--muted); font-size: .7rem; font-weight: 750; text-align: center; white-space: nowrap; }
.wizard-progress li.active { border-color: var(--lime); color: var(--ink); }
.wizard-progress li.done { border-color: var(--petrol); color: var(--petrol); }
.wizard-step { display: none; min-height: 250px; padding: 28px 0 8px; }
@@ -325,6 +329,9 @@
@media (max-width: 680px) {
.topbar { height: 62px; padding: 0 15px; }
+ .top-actions { gap: 10px; }
+ .header-links { gap: 12px; margin-right: 0; }
+ .header-links a { font-size: .75rem; }
.brand strong { font-size: 1.05rem; }
.brand-mark { width: 32px; height: 32px; }
.environment, .account-button strong { display: none; }