Files
Enelix-EMS/services/license/changes/20261006-forecast-integration/public/app.js
T
2026-10-06 17:40:45 +00:00

2594 lines
141 KiB
JavaScript
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.
(function () {
"use strict";
var state = { session: null, plants: [], orders: [], selectedOrder: [], stripeConfigured: false, vatRate: 8.1, configuredPlantId: null, checkoutConfigurationId: null, replacementOrderId: null, codeDeletionOrderId: null, plantDeletion: null, wizardStep: 0, forecastRequest: 0, forecastResult: null, forecastPlantId: null, forecastVisibleSeries: null, forecastRangeStart: null, forecastRangeEnd: null, forecastHistoryDays: 1, faultRequest: 0, wizardCatalog: null, deviceDrafts: {}, pvSurfaceDrafts: [] };
var managerPrices = {
manager_standard: { name: "Manager ohne Peak Shaving", price: 308.15, setup: 114.60 },
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", 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); };
var toastTimer;
var resetToken = "";
function escapeHtml(value) {
return String(value == null ? "" : value).replace(/[&<>"']/g, function (character) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[character];
});
}
function applyCatalog(result) {
var items = result.items || {};
state.vatRate = Number(result.vatRate || 0);
["manager_standard", "manager_peak", "manager_peak_upgrade"].forEach(function (key) {
if (!items[key]) return;
managerPrices[key] = {
name: items[key].name,
price: Number(items[key].unitAmount) / 100,
setup: Number(items[key].setupAmount || 0) / 100
};
});
document.querySelectorAll(".module-row").forEach(function (row) {
var item = items[row.dataset.key];
if (!item) return;
row.dataset.name = item.name;
row.dataset.price = String(Number(item.unitAmount) / 100);
row.dataset.setup = String(Number(item.setupAmount || 0) / 100);
row.dataset.termMonths = String(Number(item.termMonths || 0));
row.children[1].textContent = money.format(Number(item.unitAmount) / 100) + (item.termMonths ? " / Jahr" : "");
row.children[2].textContent = item.termMonths ? "–" : money.format(Number(item.setupAmount || 0) / 100);
});
byId("plantManagerStandardPrice").textContent = money.format(managerPrices.manager_standard.price) + " netto";
byId("plantManagerPeakPrice").textContent = money.format(managerPrices.manager_peak.price) + " netto";
byId("billingBasePrice").textContent = money.format(Number(items.cost_report?.unitAmount || 0) / 100) + " netto";
byId("billingPowerPrice").textContent = money.format(Number(items.cost_meter_power?.unitAmount || 0) / 100) + " netto pro Zähler";
byId("billingAuxPrice").textContent = money.format(Number(items.cost_meter_aux?.unitAmount || 0) / 100) + " netto pro Zähler";
byId("vatLabel").textContent = "MwSt. " + state.vatRate.toLocaleString("de-CH") + " %";
}
async function api(path, options) {
var request = Object.assign({ headers: {} }, options || {});
request.headers = Object.assign({}, request.headers);
if (request.body && typeof request.body !== "string") {
request.headers["Content-Type"] = "application/json";
request.body = JSON.stringify(request.body);
}
if (state.session && state.session.csrfToken && request.method && request.method !== "GET") request.headers["X-CSRF-Token"] = state.session.csrfToken;
var response = await fetch(path, request);
var result = await response.json().catch(function () { return {}; });
if (!response.ok) throw new Error(result.error || "Die Anfrage konnte nicht verarbeitet werden.");
return result;
}
function toast(message) {
var node = byId("toast");
node.textContent = message;
node.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { node.classList.remove("show"); }, 3600);
}
function setBusy(form, busy) {
var button = form.querySelector("button[type='submit']");
if (!button.dataset.label) button.dataset.label = button.textContent;
button.disabled = busy;
button.textContent = busy ? "Bitte warten …" : button.dataset.label;
}
function switchAuth(mode) {
var login = mode === "login";
byId("authTabs").classList.remove("hidden");
byId("loginTab").classList.toggle("active", login);
byId("registerTab").classList.toggle("active", !login);
byId("loginForm").classList.toggle("hidden", !login);
byId("registerForm").classList.toggle("hidden", login);
byId("forgotForm").classList.add("hidden");
byId("resetForm").classList.add("hidden");
}
function specialAuth(formId) {
byId("authTabs").classList.add("hidden");
["loginForm", "registerForm", "forgotForm", "resetForm"].forEach(function (id) { byId(id).classList.toggle("hidden", id !== formId); });
}
function authNotice(message, error) {
var notice = byId("authNotice");
notice.textContent = message || "";
notice.classList.toggle("hidden", !message);
notice.classList.toggle("error", Boolean(error));
}
function initials(name) {
return String(name || "EN").split(/\s+/).slice(0, 2).map(function (part) { return part[0]; }).join("").toUpperCase();
}
function showAuth() {
++state.forecastRequest;
if (integratedPlanner) integratedPlanner.dispose();
integratedPlanner = null;
byId("portalApp").classList.add("hidden");
byId("authScreen").classList.remove("hidden");
}
function workspaceOptions() {
var organizations = state.session.organizations || [];
if (!organizations.length) return "<option value=''>Persönlicher Bereich</option>";
return organizations.map(function (organization) { return "<option value='" + organization.id + "'>" + escapeHtml(organization.name) + "</option>"; }).join("");
}
function renderSession() {
var session = state.session;
var context = session.organization;
var organizations = session.organizations || [];
byId("topInitials").textContent = initials(session.user.name);
byId("topName").textContent = session.user.name;
byId("overviewOrg").textContent = context.name;
byId("billingOrg").textContent = context.name;
byId("accountName").textContent = session.user.name;
byId("accountEmail").textContent = session.user.email;
byId("accountOrg").textContent = context.name;
byId("workspaceRole").textContent = roleLabels[context.role] || context.role;
["workspaceSelect", "accountWorkspaceSelect"].forEach(function (id) {
byId(id).innerHTML = workspaceOptions();
byId(id).value = context.id == null ? "" : String(context.id);
byId(id).disabled = organizations.length <= 1;
});
byId("accountWorkspaceLabel").classList.toggle("hidden", organizations.length <= 1);
byId("organizationCreateSection").classList.toggle("hidden", organizations.length > 0);
var canInvite = context.type === "organization" && ["owner", "admin"].includes(context.role);
byId("invitationSection").classList.toggle("hidden", !canInvite);
var canManage = context.type === "personal" || ["owner", "admin", "operator"].includes(context.role);
document.querySelectorAll("[data-open-plant]").forEach(function (button) { button.disabled = !canManage; });
}
async function acceptStoredInvitation() {
var token = sessionStorage.getItem("enelix_invitation_token");
if (!token || !state.session) return;
try {
var result = await api("/api/invitations/accept", { method: "POST", body: { token: token } });
sessionStorage.removeItem("enelix_invitation_token");
state.session = result.session;
renderSession();
toast(result.message);
} catch (error) {
sessionStorage.removeItem("enelix_invitation_token");
toast(error.message);
}
}
async function showPortal(session) {
state.session = session;
byId("authScreen").classList.add("hidden");
byId("portalApp").classList.remove("hidden");
renderSession();
await acceptStoredInvitation();
await loadPortalHealth();
await Promise.all([loadPlants(), loadOrders(), loadInvitations()]);
var initialView = location.hash.replace("#", "");
var allowedViews = ["overview", "licenses", "forecast", "faults", "account", "configurator"];
showView(allowedViews.includes(initialView) ? initialView : "overview");
var query = new URLSearchParams(location.search);
if (query.get("checkout") === "success") toast("Zahlung abgeschlossen. Stripe bestätigt die Freischaltung in wenigen Sekunden.");
if (query.get("checkout") === "free") toast("Kostenlose Jahreslizenz wurde freigeschaltet.");
if (query.get("checkout") === "cancelled") toast("Zahlung abgebrochen. Es wurde keine Lizenz aktiviert.");
if (query.has("checkout")) history.replaceState(null, "", location.pathname + location.hash);
}
function showView(name) {
if (name !== "forecast") {
++state.forecastRequest;
if (integratedPlanner) integratedPlanner.dispose();
integratedPlanner = null;
}
document.querySelectorAll(".view").forEach(function (view) { view.classList.toggle("active", view.id === "view-" + name); });
document.querySelectorAll(".nav-item").forEach(function (item) { item.classList.toggle("active", item.dataset.view === name); });
history.replaceState(null, "", location.pathname + location.search + "#" + name);
if (name === "account") {
loadInvitations();
loadOrders();
}
if (name === "forecast") updateForecast();
if (name === "faults") updateFaults();
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function loadPortalHealth() {
var health = await api("/healthz");
state.stripeConfigured = health.stripe === "configured";
["stripeStatus", "billingStripeStatus"].forEach(function (id) {
var node = byId(id);
if (!node) return;
node.textContent = state.stripeConfigured ? "Stripe Sandbox bereit" : "Stripe Sandbox offen";
node.className = "status " + (state.stripeConfigured ? "available" : "review");
});
if (byId("stripeProviderState")) byId("stripeProviderState").textContent = state.stripeConfigured ? "Bereit" : "Nicht konfiguriert";
}
async function loadPlants() {
state.plants = (await api("/api/plants")).plants || [];
renderPlants();
}
async function loadOrders() {
if (!state.session) return;
state.orders = (await api("/api/orders")).orders || [];
renderOrders();
renderPlants();
}
async function loadInvitations() {
if (!state.session || byId("invitationSection").classList.contains("hidden")) return void (byId("pendingInvitations").innerHTML = "");
var invitations = (await api("/api/invitations")).invitations || [];
byId("pendingInvitations").innerHTML = invitations.map(function (invitation) {
return "<div><span><strong>" + escapeHtml(invitation.invited_email) + "</strong><small>" + escapeHtml(roleLabels[invitation.role] || invitation.role) + "</small></span><small>offen</small></div>";
}).join("");
}
function managerLabel(value) {
return value === "billing_manager"
? "Abrechnungsmanager"
: (managerPrices[value] || managerPrices.manager_standard).name;
}
function connectionLabel(plant) { return plant.installation_id ? plant.installation_id : "Noch nicht verknüpft"; }
function activationOrderForPlant(plantId) {
var paid = state.orders.filter(function (order) { return order.plant_id === plantId && order.status === "paid"; });
var managerOrders = paid.filter(function (order) {
return (order.lines || []).some(function (line) {
return line.line_type === "license" && ["manager_standard", "manager_peak"].includes(line.catalog_key);
});
});
var candidates = managerOrders.length ? managerOrders : paid;
return candidates.find(function (order) { return Boolean(order.activated_installation_id); })
|| candidates.find(function (order) { return Boolean(order.code_hint); })
|| candidates[candidates.length - 1]
|| null;
}
function openReplacementDialog(orderId, plantName, connected) {
state.replacementOrderId = orderId;
byId("replaceCodeSystem").textContent = plantName;
byId("replaceCodeBinding").classList.toggle("hidden", !connected);
byId("replaceCodeError").textContent = "";
byId("replaceCodeDialog").showModal();
}
function openCodeDeletionDialog(orderId, plantName) {
state.codeDeletionOrderId = orderId;
byId("deleteCodeSystem").textContent = plantName;
byId("deleteCodeError").textContent = "";
byId("deleteCodeDialog").showModal();
}
function openPlantDeletionDialog(plantId, plantName, hasPaidLicense) {
state.plantDeletion = { id: plantId, name: plantName, hasPaidLicense: hasPaidLicense };
byId("deletePlantSystem").textContent = plantName;
byId("deletePaidSystem").textContent = plantName;
byId("deletePlantError").textContent = "";
byId("deletePaidError").textContent = "";
byId("deletePlantDialog").showModal();
}
function renderPlants() {
var hasPlants = state.plants.length > 0;
var managerPlants = state.plants.filter(function (plant) { return plant.manager_variant !== "billing_manager"; });
var hasManagerPlants = managerPlants.length > 0;
var paidOrders = state.orders.filter(function (order) { return order.status === "paid"; });
var paidLicenses = paidOrders.reduce(function (sum, order) {
return sum + (order.lines || []).reduce(function (lineSum, line) {
return lineSum + (line.line_type === "license" ? Number(line.quantity) : 0);
}, 0);
}, 0);
byId("metricPlants").textContent = state.plants.length;
byId("metricManagers").textContent = paidLicenses;
byId("metricConnected").textContent = state.plants.filter(function (plant) { return Boolean(plant.installation_id); }).length;
byId("moduleNoPlant").classList.toggle("hidden", hasPlants);
byId("moduleConfigurator").classList.toggle("hidden", !hasPlants);
byId("forecastEmpty").classList.toggle("hidden", hasManagerPlants);
byId("forecastContent").classList.toggle("hidden", !hasManagerPlants);
byId("faultEmpty").classList.toggle("hidden", hasManagerPlants);
byId("faultContent").classList.toggle("hidden", !hasManagerPlants);
var canDelete = state.session.organization.type === "personal" || ["owner", "admin"].includes(state.session.organization.role);
byId("overviewPlants").innerHTML = hasPlants ? "<div class='license-list'>" + state.plants.map(function (plant) {
var orders = state.orders.filter(function (order) { return order.plant_id === plant.id; });
var paid = orders.filter(function (order) { return order.status === "paid"; });
var licenseCount = paid.reduce(function (sum, order) {
return sum + (order.lines || []).reduce(function (lineSum, line) {
return lineSum + (line.line_type === "license" ? Number(line.quantity) : 0);
}, 0);
}, 0);
var activated = paid.some(function (order) { return Boolean(order.activated_installation_id); });
var licenseState = activated ? "Verwendet" : (paid.length ? "Bezahlt" : "Noch nicht gekauft");
var stateClass = activated ? "available" : (paid.length ? "neutral" : "review");
var codeOrder = activationOrderForPlant(plant.id);
var codeAction = codeOrder
? "<button class='button compact code-replacement' data-replace-code='" + escapeHtml(codeOrder.id) + "' data-replace-plant='" + escapeHtml(plant.name) + "' data-replace-connected='" + (activated ? "true" : "false") + "'>" + (codeOrder.code_hint === "Gelöscht" ? "Neuen Lizenzcode generieren" : "Lizenzcode neu generieren") + "</button>"
: "";
var deleteCodeAction = canDelete && codeOrder && codeOrder.code_hint && codeOrder.code_hint !== "Gelöscht"
? "<button class='button compact destructive-action' data-delete-code='" + escapeHtml(codeOrder.id) + "' data-delete-code-plant='" + escapeHtml(plant.name) + "'>Lizenzcode löschen</button>"
: "";
var deletePlantAction = canDelete
? "<button class='text-action destructive-link' data-delete-plant='" + escapeHtml(plant.id) + "' data-delete-plant-name='" + escapeHtml(plant.name) + "' data-delete-plant-paid='" + (paid.length ? "true" : "false") + "'>Anlage löschen</button>"
: "";
var billingPlant = plant.manager_variant === "billing_manager";
var extendAction = billingPlant
? "<button class='button compact' data-billing-order='" + escapeHtml(plant.id) + "'>Zähler erweitern</button>"
: "<button class='button compact' data-order='" + escapeHtml(plant.id) + "'>Erweitern</button>";
return "<div class='license-row'><span class='license-main'><strong>" + escapeHtml(plant.name) + "</strong><small>" + escapeHtml(plant.location) + "</small></span>" +
"<span><small>" + (billingPlant ? "Lizenztyp" : "Manager") + "</small><strong>" + escapeHtml(managerLabel(plant.manager_variant)) + "</strong></span>" +
"<span><small>Lizenzen</small><strong>" + licenseCount + "</strong></span>" +
"<span><small>Verwendung</small><b class='status " + stateClass + "'>" + licenseState + "</b></span>" +
"<span><small>Verbindung</small><strong>" + escapeHtml(plant.installation_id ? (billingPlant ? "Abrechnung verbunden" : "Manager verbunden") : "Nicht verbunden") + "</strong></span>" +
"<span class='license-actions'>" + extendAction + codeAction + deleteCodeAction + deletePlantAction + "</span></div>";
}).join("") + "</div>" : "<div class='empty-state'><strong>Noch keine Lizenz vorhanden</strong><p>Lege eine Lizenz direkt an oder starte mit dem Systemkonfigurator.</p><button class='button primary' type='button' data-open-new-license>Neue Lizenz</button></div>";
var options = managerPlants.map(function (plant) { return "<option value='" + escapeHtml(plant.id) + "'>" + escapeHtml(plant.name) + "</option>"; }).join("");
var orderValue = byId("orderPlant").value;
var forecastValue = byId("forecastPlant").value;
var faultValue = byId("faultPlant").value;
byId("orderPlant").innerHTML = options;
byId("forecastPlant").innerHTML = options;
byId("faultPlant").innerHTML = options;
if (managerPlants.some(function (plant) { return plant.id === orderValue; })) byId("orderPlant").value = orderValue;
if (managerPlants.some(function (plant) { return plant.id === forecastValue; })) byId("forecastPlant").value = forecastValue;
if (managerPlants.some(function (plant) { return plant.id === faultValue; })) byId("faultPlant").value = faultValue;
if (state.configuredPlantId && managerPlants.some(function (plant) { return plant.id === state.configuredPlantId; })) byId("orderPlant").value = state.configuredPlantId;
prepareOrderEditor();
updateForecast();
updateFaults();
}
function renderOrders() {
var labels = { creating_checkout: "Checkout wird erstellt", checkout_open: "Zahlung offen", checkout_failed: "Checkout fehlgeschlagen", payment_processing: "Zahlung wird verarbeitet", payment_failed: "Zahlung fehlgeschlagen", expired: "Abgelaufen", paid: "Bezahlt" };
var canDelete = state.session.organization.type === "personal" || ["owner", "admin"].includes(state.session.organization.role);
byId("ordersBody").innerHTML = state.orders.length ? state.orders.map(function (order) {
var count = (order.lines || []).reduce(function (sum, line) { return sum + (line.line_type === "license" ? line.quantity : 0); }, 0);
var hasLicenseLines = (order.lines || []).some(function (line) { return line.line_type === "license"; });
var action = "–";
if (order.status === "paid" && !hasLicenseLines) {
action = "<small>Generator / Ersteinrichtung bezahlt</small>";
} else if (order.status === "paid" && order.code_hint === "Gelöscht") {
action = "<button class='text-action' data-code='" + order.id + "' data-rotate='true'>Neuen Code per E-Mail senden</button><small>Code gelöscht</small>";
} else if (order.status === "paid" && order.code_hint) {
action = "<button class='text-action' data-code='" + order.id + "' data-rotate='true'>Neuen Code per E-Mail senden</button>" +
(canDelete ? "<button class='text-action destructive-link' data-delete-code='" + order.id + "' data-delete-code-plant='" + escapeHtml(order.plant_name) + "'>Code löschen</button>" : "") +
"<small>" + escapeHtml(order.activated_installation_id ? "Verknüpft · " + order.code_hint : order.code_hint) + "</small>";
} else if (order.status === "paid") action = "<button class='button compact' data-code='" + order.id + "'>Code per E-Mail senden</button>";
var receipt = order.status === "paid" ? "<a class='button compact' href='/api/orders/" + encodeURIComponent(order.id) + "/receipt.pdf'>PDF</a>" : "–";
return "<tr><td>" + escapeHtml(new Date(order.created_at + "Z").toLocaleDateString("de-CH")) + "</td><td>" + escapeHtml(order.plant_name) + "</td><td>" + count + "</td><td>" + escapeHtml(money.format(order.amount_total / 100)) + "</td><td><span class='status " + (order.status === "paid" ? "available" : "review") + "'>" + escapeHtml(labels[order.status] || order.status) + "</span></td><td class='code-cell'>" + action + "</td><td>" + receipt + "</td></tr>";
}).join("") : "<tr><td colspan='7'>Noch keine Bestellungen in dieser Organisation.</td></tr>";
}
function selectedPlant(selectId) {
return state.plants.find(function (plant) { return plant.id === byId(selectId).value && plant.manager_variant !== "billing_manager"; })
|| state.plants.find(function (plant) { return plant.manager_variant !== "billing_manager"; })
|| null;
}
function plantRecommendation(plant) {
if (!plant || !plant.recommendation_json) return null;
try { return JSON.parse(plant.recommendation_json); } catch (_) { return null; }
}
function entitlementTime(value) {
return value ? Date.parse(String(value).replace(" ", "T") + "Z") : null;
}
function entitlementIsActive(entitlement) {
var now = Date.now();
var from = entitlementTime(entitlement.valid_from);
var until = entitlementTime(entitlement.valid_until);
return entitlement.status === "active" && (!from || from <= now) && (!until || until > now);
}
function paidLicenseSummary(plantId) {
var summary = {};
state.orders.filter(function (order) {
return order.plant_id === plantId && order.status === "paid";
}).forEach(function (order) {
(order.entitlements || []).filter(entitlementIsActive).forEach(function (entitlement) {
summary[entitlement.catalog_key] = Number(summary[entitlement.catalog_key] || 0) + Number(entitlement.quantity || 0);
});
});
return summary;
}
function latestEntitlementUntil(plantId, catalogKey) {
var values = [];
state.orders.filter(function (order) {
return order.plant_id === plantId && order.status === "paid";
}).forEach(function (order) {
(order.entitlements || []).forEach(function (entitlement) {
if (entitlement.catalog_key === catalogKey && entitlement.valid_until) values.push(entitlement.valid_until);
});
});
return values.sort().at(-1) || null;
}
function paidSetupSummary(plantId) {
var summary = {};
state.orders.filter(function (order) {
return order.plant_id === plantId && order.status === "paid";
}).forEach(function (order) {
(order.lines || []).forEach(function (line) {
if (line.line_type !== "setup") return;
summary[line.catalog_key] = Number(summary[line.catalog_key] || 0) + Number(line.quantity || 0);
});
});
return summary;
}
function paidManagerVariant(plant) {
var managerKeys = [];
state.orders.filter(function (order) {
return order.plant_id === plant.id && order.status === "paid";
}).forEach(function (order) {
(order.lines || []).forEach(function (line) {
if (line.line_type === "license" && ["manager_standard", "manager_peak"].includes(line.catalog_key)) managerKeys.push(line.catalog_key);
});
});
if (managerKeys.includes("manager_peak")) return "manager_peak";
if (managerKeys.includes("manager_standard")) return "manager_standard";
return null;
}
function currentManagerVariant(plant) {
return paidManagerVariant(plant) || plantRecommendation(plant)?.managerVariant || plant.manager_variant || "manager_standard";
}
function selectedManagerVariant(plant) {
var ownedManager = paidManagerVariant(plant);
if (!ownedManager) return plantRecommendation(plant)?.managerVariant || plant.manager_variant || "manager_standard";
if (ownedManager === "manager_standard" && byId("peakUpgrade").checked) return "manager_peak";
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;
var configurationPurchase = Boolean(configurationId);
state.checkoutConfigurationId = configurationPurchase ? configurationId : null;
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);
byId("ownedManager").textContent = ownedManager ? "Bereits gekauft" : "Wird beim Erstkauf einmalig berechnet";
byId("peakUpgradeOption").classList.toggle("hidden", ownedManager !== "manager_standard");
byId("peakUpgrade").checked = ownedManager === "manager_standard" && desiredManager === "manager_peak";
byId("peakUpgrade").disabled = configurationPurchase;
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 - Number(coverage[row.dataset.key] || 0));
var setupQuantity = configurationPurchase
? Math.max(0, recommendedQuantity - Number(setupOwned[row.dataset.key] || 0))
: 0;
var quantity = Math.max(licenseQuantity, setupQuantity);
row.dataset.licenseQuantity = String(licenseQuantity);
row.dataset.setupQuantity = String(setupQuantity);
var validUntil = latestEntitlementUntil(plant.id, row.dataset.key);
var ownedLabel = row.querySelector(".owned-count");
ownedLabel.textContent = Number(row.dataset.termMonths || 0) > 0
? (validUntil ? "Gültig bis " + new Date(entitlementTime(validUntil)).toLocaleDateString("de-CH") : "Nicht aktiviert")
: "Vorhanden: " + ownedQuantity;
row.querySelector(".module-check").checked = configurationPurchase
? quantity > 0
: licenseQuantity > 0;
row.querySelector(".qty").value = quantity > 0 ? quantity : 1;
row.querySelector(".module-check").disabled = configurationPurchase;
row.querySelector(".qty").disabled = configurationPurchase;
});
updateOrder();
}
function updateOrder() {
var plant = selectedPlant("orderPlant");
if (!plant) return;
var configurationPurchase = Boolean(state.checkoutConfigurationId);
var ownedManager = paidManagerVariant(plant);
var setupOwned = paidSetupSummary(plant.id);
var targetManager = selectedManagerVariant(plant);
var managerItem = null;
if (!ownedManager) managerItem = managerPrices[targetManager] || managerPrices.manager_standard;
else if (ownedManager === "manager_standard" && targetManager === "manager_peak") managerItem = managerPrices.manager_peak_upgrade;
var managerSetupPaid = Number(setupOwned.manager_standard || 0) > 0
|| Number(setupOwned.manager_peak || 0) > 0;
var managerSetupPrice = configurationPurchase && !managerSetupPaid
? (managerPrices[targetManager] || managerPrices.manager_standard).setup
: 0;
byId("managerPrice").textContent = managerItem ? money.format(managerItem.price) : money.format(0);
byId("managerSetup").textContent = configurationPurchase
? (managerSetupPrice > 0
? "Generator / Ersteinrichtung " + money.format(managerSetupPrice)
: "Ersteinrichtung bereits bezahlt")
: (managerItem ? "Nur Lizenz" : "Kein weiterer Managerkauf");
state.selectedOrder = [];
document.querySelectorAll(".module-row").forEach(function (row) {
var quantity = Math.min(999, Math.max(1, Number(row.querySelector(".qty").value) || 1));
row.querySelector(".qty").value = quantity;
var licenseQuantity = configurationPurchase
? Number(row.dataset.licenseQuantity || 0)
: (row.querySelector(".module-check").checked ? quantity : 0);
var setupQuantity = configurationPurchase
? Number(row.dataset.setupQuantity || 0)
: 0;
if (licenseQuantity > 0 || setupQuantity > 0) {
state.selectedOrder.push({
key: row.dataset.key,
name: row.dataset.name,
price: Number(row.dataset.price),
setup: Number(row.dataset.setup),
licenseQuantity: licenseQuantity,
setupQuantity: setupQuantity
});
}
});
var managerLicensePrice = managerItem ? managerItem.price : 0;
var licensePrice = managerLicensePrice + state.selectedOrder.reduce(function (sum, item) {
return sum + item.price * item.licenseQuantity;
}, 0);
var setupPrice = managerSetupPrice + state.selectedOrder.reduce(function (sum, item) {
return sum + item.setup * item.setupQuantity;
}, 0);
var items = [];
if (managerItem) items.push({ name: managerItem.name, quantity: 1, total: managerItem.price });
state.selectedOrder.forEach(function (item) {
if (item.licenseQuantity > 0) {
items.push({ name: item.name, quantity: item.licenseQuantity, total: item.price * item.licenseQuantity });
}
});
if (managerSetupPrice > 0) {
items.push({ name: "Generator / Ersteinrichtung Manager", quantity: 1, total: managerSetupPrice });
}
state.selectedOrder.forEach(function (item) {
if (item.setupQuantity > 0) {
items.push({
name: "Generator / Ersteinrichtung " + item.name,
quantity: item.setupQuantity,
total: item.setup * item.setupQuantity
});
}
});
byId("orderItems").innerHTML = items.length
? items.map(function (item) { return "<div class='order-item'><span>" + escapeHtml(item.name) + " × " + item.quantity + "</span><strong>" + money.format(item.total) + "</strong></div>"; }).join("")
: "<p>Keine zusätzliche Lizenz oder Ersteinrichtung erforderlich.</p>";
var netTotal = licensePrice + setupPrice;
var vatTotal = Math.round(netTotal * state.vatRate) / 100;
byId("moduleTotal").textContent = money.format(licensePrice);
byId("setupTotal").textContent = money.format(setupPrice);
byId("setupTotalRow").classList.toggle("hidden", !configurationPurchase);
byId("netTotal").textContent = money.format(netTotal);
byId("vatTotal").textContent = money.format(vatTotal);
byId("grandTotal").textContent = money.format(netTotal + vatTotal);
var canCheckout = state.session.organization.type === "personal" || ["owner", "admin", "billing"].includes(state.session.organization.role);
var checkoutButton = byId("checkoutButton");
var freeOrder = netTotal === 0 && items.length > 0;
checkoutButton.dataset.free = freeOrder ? "true" : "false";
checkoutButton.textContent = freeOrder ? "Kostenlos freischalten" : "Mit Stripe bezahlen";
checkoutButton.disabled = (!state.stripeConfigured && !freeOrder) || !canCheckout || items.length === 0;
}
var forecastSeriesLabels = {
PV: "PV-Messwert",
Hausverbrauch: "Verbrauchsmesswert",
Netzleistung: "Netzmesswert",
SOC: "Batterie-SOC",
prog_var_1: "PV-Prognose · Modell 1",
prog_var_2: "Verbrauchsprognose · Modell 2",
prog_var_3: "Netzfahrplan · Modell 3 (+ Bezug / − Einspeisung)",
prog_var_3_battery: "Batterieplan · Modell 3 (+ Laden / − Entladen)",
prog_var_10: "PV-Prognose · Modell 10",
prog_var_11: "Verbrauchsprognose · Modell 11",
prog_var_13: "Netzfahrplan · Modell 13 (+ Bezug / − Einspeisung)",
prog_var_13_battery: "Batterieplan · Modell 13 (+ Laden / − Entladen)",
prog_var_21: "PV-Prognose · Modell 21",
prog_var_22: "Verbrauchsprognose · Modell 22",
prog_var_23: "Netzfahrplan · Modell 23 (+ Bezug / − Einspeisung)",
prog_var_23_battery: "Batterieplan · Modell 23 (+ Laden / − Entladen)"
};
var forecastColors = {
PV: "#006b62",
prog_var_1: "#2a9d8f",
prog_var_10: "#62bbb0",
prog_var_21: "#98d7cf",
Hausverbrauch: "#b93242",
prog_var_2: "#dd5a68",
prog_var_11: "#ea8d98",
prog_var_22: "#f2bcc2",
Netzleistung: "#2f6ecb",
prog_var_3: "#5a8ce0",
prog_var_13: "#86adeb",
prog_var_23: "#b2caf2",
prog_var_3_battery: "#8c4b78",
prog_var_13_battery: "#a96b91",
prog_var_23_battery: "#c38eac",
SOC: "#d68a00"
};
var forecastFamilies = [
{ pv: "prog_var_1", load: "prog_var_2", schedule: "prog_var_3" },
{ pv: "prog_var_10", load: "prog_var_11", schedule: "prog_var_13" },
{ pv: "prog_var_21", load: "prog_var_22", schedule: "prog_var_23" }
];
function forecastSeriesLabel(key) {
return forecastSeriesLabels[key] || key;
}
function forecastSeriesColor(key) {
return forecastColors[key] || "#7b8784";
}
function forecastDefaultVisibleSeries(result) {
var available = new Set(((result && result.series) || []).map(function (entry) { return entry.key; }));
var schedule = (result && result.scheduleSourceModel)
|| ["prog_var_3", "prog_var_13", "prog_var_23"].find(function (key) { return available.has(key); })
|| "prog_var_3";
var family = forecastFamilies.find(function (entry) { return entry.schedule === schedule; })
|| forecastFamilies[0];
return new Set([
"PV",
"Hausverbrauch",
"Netzleistung",
"SOC",
family.pv,
family.load,
schedule,
schedule + "_battery"
]);
}
function forecastInputValue(timestamp) {
var date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return "";
var local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function forecastWindowLabel(start, end) {
var hours = Math.max(0, Math.round((end - start) / 3600000));
if (hours >= 48 && hours % 24 === 0) return (hours / 24) + " Tage";
return hours + " h";
}
function formatForecastHoverDate(value) {
var date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date);
}
function formatForecastHoverValue(key, value) {
var number = Number(value);
if (!Number.isFinite(number)) return "-";
if (key === "SOC") return number.toLocaleString("de-CH", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " %";
return number.toLocaleString("de-CH", { maximumFractionDigits: 0 }) + " W";
}
function attachForecastHover(series, minTime, maxTime, width, height, padding) {
var chart = byId("forecastChart");
var tooltip = byId("forecastTooltip");
chart.onpointermove = function (event) {
var rect = chart.getBoundingClientRect();
var sx = (event.clientX - rect.left) / rect.width * width;
if (sx < padding.left || sx > width - padding.right) {
tooltip.classList.add("hidden");
var outsideLine = byId("forecastHoverLine");
if (outsideLine) outsideLine.classList.add("hidden");
return;
}
var time = minTime + (sx - padding.left) / (width - padding.left - padding.right) * (maxTime - minTime);
var tolerance = Math.max(30, Number(state.forecastResult && state.forecastResult.intervalMinutes) || 15) * 60 * 1000 * 1.6;
var rows = series.map(function (entry) {
var nearest = null;
entry.points.forEach(function (point) {
var pt = Date.parse(point.time), value = Number(point.value);
if (!Number.isFinite(pt) || !Number.isFinite(value)) return;
if (!nearest || Math.abs(pt - time) < Math.abs(nearest.time - time)) nearest = { time: pt, value: value };
});
return nearest && Math.abs(nearest.time - time) <= tolerance ? { key: entry.key, time: nearest.time, value: nearest.value } : null;
}).filter(Boolean);
if (!rows.length) return;
var referenceTime = rows.reduce(function (best, row) { return Math.abs(row.time - time) < Math.abs(best - time) ? row.time : best; }, rows[0].time);
tooltip.innerHTML = "<strong>" + escapeHtml(formatForecastHoverDate(referenceTime)) + "</strong>" + rows.map(function (row) {
return "<span><i style='--series-color:" + forecastSeriesColor(row.key) + "'></i><b>" + escapeHtml(forecastSeriesLabel(row.key)) + "</b><em>" + escapeHtml(formatForecastHoverValue(row.key, row.value)) + "</em></span>";
}).join("");
tooltip.classList.remove("hidden");
tooltip.style.left = Math.min(Math.max(event.clientX - rect.left + 14, 8), rect.width - tooltip.offsetWidth - 8) + "px";
tooltip.style.top = Math.min(Math.max(event.clientY - rect.top + 14, 8), rect.height - tooltip.offsetHeight - 8) + "px";
var line = byId("forecastHoverLine");
if (line) { line.setAttribute("x1", sx); line.setAttribute("x2", sx); line.classList.remove("hidden"); }
};
chart.onpointerleave = function () {
tooltip.classList.add("hidden");
var line = byId("forecastHoverLine");
if (line) line.classList.add("hidden");
};
}
function formatForecastDate(value) {
var date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return "–";
return new Intl.DateTimeFormat("de-CH", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
}).format(date);
}
function setForecastState(message) {
byId("forecastState").textContent = message;
byId("forecastState").classList.remove("hidden");
byId("forecastChart").classList.add("hidden");
}
function resetForecastChart() {
byId("forecastChart").innerHTML = "";
byId("forecastChart").classList.add("hidden");
byId("forecastSeriesControls").innerHTML = "";
byId("forecastLegend").innerHTML = "";
byId("forecastLegend").classList.add("hidden");
byId("forecastTooltip").classList.add("hidden");
byId("forecastLastUpdated").textContent = "–";
byId("forecastSeriesCount").textContent = "0";
byId("forecastQualityStatus").className = "status review";
byId("forecastQualityStatus").textContent = "Aufbauphase";
byId("forecastQualityState").textContent = "Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.";
byId("forecastQualityState").classList.remove("hidden");
byId("forecastQualityTable").classList.add("hidden");
byId("forecastQualityBody").innerHTML = "";
byId("forecastConfigSection").classList.add("hidden");
byId("forecastConfigError").textContent = "";
}
function renderForecastLicenseWarning(plant) {
var warning = byId("forecastOfflineWarning");
var lastSeen = plant.license_last_seen_at
? new Date(String(plant.license_last_seen_at).replace(" ", "T") + "Z")
: null;
var now = Date.now();
var refreshDue = lastSeen ? lastSeen.getTime() + 24 * 60 * 60 * 1000 : 0;
var offlineUntil = lastSeen ? lastSeen.getTime() + 14 * 24 * 60 * 60 * 1000 : 0;
var showOffline = Boolean(plant.installation_id && lastSeen && !Number.isNaN(lastSeen.getTime()) && now > refreshDue);
warning.classList.toggle("hidden", !showOffline);
if (!showOffline) return;
var remainingDays = Math.max(0, Math.ceil((offlineUntil - now) / (24 * 60 * 60 * 1000)));
byId("forecastOfflineTitle").textContent = remainingDays > 0
? "Kein Internet am Manager erkannt"
: "Offline-Freigabe abgelaufen";
byId("forecastOfflineText").textContent = remainingDays > 0
? "Die lokale Lizenzfreigabe läuft in " + remainingDays + " " + (remainingDays === 1 ? "Tag" : "Tagen") + " ab, falls keine Verbindung zum Lizenzserver hergestellt wird."
: "Der Manager benötigt eine Internetverbindung für eine neue Lizenzprüfung.";
}
function formatForecastMetric(value, digits, suffix) {
if (value === null || value === undefined || value === "") return "–";
var number = Number(value);
if (!Number.isFinite(number)) return "–";
return number.toLocaleString("de-CH", {
minimumFractionDigits: digits,
maximumFractionDigits: digits
}) + (suffix || "");
}
function renderForecastQuality(quality, capabilities) {
var available = quality && Array.isArray(quality.series)
? quality.series.filter(function (entry) {
return entry && entry.key && Number(entry.samples) > 0;
})
: [];
var byKey = new Map(available.map(function (entry) { return [entry.key, entry]; }));
var rows = [];
var addPreferred = function (keys) {
var candidates = keys.map(function (key) { return byKey.get(key); }).filter(Boolean);
candidates.sort(function (left, right) {
var a = Number.isFinite(Number(left.r2)) ? Number(left.r2) : -Infinity;
var b = Number.isFinite(Number(right.r2)) ? Number(right.r2) : -Infinity;
return b - a;
});
if (candidates.length) rows.push(candidates[0]);
};
if (capabilities && capabilities.pv) addPreferred(["prog_var_1", "prog_var_10", "prog_var_21"]);
if (capabilities && capabilities.load) addPreferred(["prog_var_2", "prog_var_11", "prog_var_22"]);
var status = byId("forecastQualityStatus");
if (!rows.length) {
status.className = "status review";
status.textContent = "Aufbauphase";
byId("forecastQualityState").textContent = "Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.";
byId("forecastQualityState").classList.remove("hidden");
byId("forecastQualityTable").classList.add("hidden");
byId("forecastQualityBody").innerHTML = "";
return;
}
var mature = rows.length > 0
&& rows.every(function (entry) { return Number(entry.samples) >= 2016; });
status.className = mature ? "status available" : "status review";
status.textContent = mature ? "Vergleich bereit" : "Aufbauphase";
byId("forecastQualityBody").innerHTML = rows.map(function (entry) {
var target = entry.target === "PV" ? "PV" : "Verbrauch";
return "<tr>"
+ "<td><strong>" + escapeHtml(forecastSeriesLabel(entry.key)) + "</strong></td>"
+ "<td>" + escapeHtml(target) + "</td>"
+ "<td>" + formatForecastMetric(entry.r2, 3, "") + "</td>"
+ "<td>" + formatForecastMetric(entry.mae, 0, " W") + "</td>"
+ "<td>" + formatForecastMetric(entry.rmse, 0, " W") + "</td>"
+ "<td>" + Number(entry.samples).toLocaleString("de-CH") + "</td>"
+ "</tr>";
}).join("");
byId("forecastQualityState").classList.add("hidden");
byId("forecastQualityTable").classList.remove("hidden");
}
function renderForecastResult(result) {
state.forecastResult = result;
if (!state.forecastVisibleSeries) state.forecastVisibleSeries = forecastDefaultVisibleSeries(result);
byId("forecastLastUpdated").textContent = formatForecastDate(result.generatedAt);
byId("forecastSeriesCount").textContent = String((result.series || []).length);
renderForecastQuality(result.quality, result.forecastCapabilities || {
pv: true,
load: true,
schedule: true
});
if (result.status === "license_required") {
byId("forecastApiStatus").textContent = "Keine aktive Prognoselizenz";
return setForecastState("Für dieses System ist eine aktive Prognoselizenz erforderlich.");
}
if (result.status === "not_connected") {
byId("forecastApiStatus").textContent = "Manager noch nicht verbunden";
return setForecastState("Verbinde den Manager zuerst mit dem Lizenzcode dieses Systems.");
}
if (result.status === "no_data") {
byId("forecastApiStatus").textContent = "Verbindung bereit, noch keine Daten";
return setForecastState("Für dieses System liegen im gewählten Zeitfenster noch keine Prognosedaten vor.");
}
byId("forecastApiStatus").textContent = result.configured
? "Prognose aktiv"
: "Daten empfangen, Konfiguration offen";
renderForecastChart(result.series || []);
}
function renderForecastChart(inputSeries) {
var priority = [
"PV", "Hausverbrauch", "Netzleistung", "SOC",
"prog_var_1", "prog_var_2", "prog_var_3", "prog_var_3_battery",
"prog_var_10", "prog_var_11", "prog_var_13", "prog_var_13_battery",
"prog_var_21", "prog_var_22", "prog_var_23", "prog_var_23_battery"
];
var availableSeries = inputSeries.filter(function (entry) {
return entry && Array.isArray(entry.points) && entry.points.length;
}).sort(function (left, right) {
var a = priority.indexOf(left.key);
var b = priority.indexOf(right.key);
return (a < 0 ? 99 : a) - (b < 0 ? 99 : b);
});
var availablePoints = [];
availableSeries.forEach(function (entry) {
entry.points.forEach(function (point) {
var time = Date.parse(point.time);
var value = Number(point.value);
if (Number.isFinite(time) && Number.isFinite(value)) availablePoints.push({ time: time, value: value });
});
});
if (!availablePoints.length) return setForecastState("Keine darstellbaren Prognosedaten vorhanden.");
var availableMin = Math.min.apply(null, availablePoints.map(function (point) { return point.time; }));
var availableMax = Math.max.apply(null, availablePoints.map(function (point) { return point.time; }));
if (!Number.isFinite(state.forecastRangeStart) || state.forecastRangeStart < availableMin || state.forecastRangeStart >= availableMax) {
state.forecastRangeStart = availableMin;
}
if (!Number.isFinite(state.forecastRangeEnd) || state.forecastRangeEnd > availableMax || state.forecastRangeEnd <= availableMin) {
state.forecastRangeEnd = availableMax;
}
if (state.forecastRangeStart >= state.forecastRangeEnd) {
state.forecastRangeStart = availableMin;
state.forecastRangeEnd = availableMax;
}
var startInput = byId("forecastRangeStart");
var endInput = byId("forecastRangeEnd");
startInput.min = forecastInputValue(availableMin);
startInput.max = forecastInputValue(availableMax);
endInput.min = forecastInputValue(availableMin);
endInput.max = forecastInputValue(availableMax);
startInput.value = forecastInputValue(state.forecastRangeStart);
endInput.value = forecastInputValue(state.forecastRangeEnd);
byId("forecastWindowValue").textContent = forecastWindowLabel(state.forecastRangeStart, state.forecastRangeEnd);
if (!state.forecastVisibleSeries) {
state.forecastVisibleSeries = forecastDefaultVisibleSeries(state.forecastResult || {});
}
byId("forecastSeriesControls").innerHTML = availableSeries.map(function (entry) {
var checked = state.forecastVisibleSeries.has(entry.key) ? " checked" : "";
var prediction = entry.key.indexOf("prog_var_") === 0 ? " prediction" : "";
var activeSchedule = state.forecastResult && state.forecastResult.scheduleSourceModel === entry.key ? " · aktiv" : "";
return "<label class='forecast-series-option'><input type='checkbox' data-forecast-series='" + escapeHtml(entry.key) + "'" + checked + "><i class='" + prediction + "' style='--series-color:" + forecastSeriesColor(entry.key) + "'></i><span>" + escapeHtml(forecastSeriesLabel(entry.key) + activeSchedule) + "</span></label>";
}).join("");
var series = availableSeries.filter(function (entry) {
return state.forecastVisibleSeries.has(entry.key);
}).map(function (entry) {
return {
key: entry.key,
points: entry.points.filter(function (point) {
var time = Date.parse(point.time);
return Number.isFinite(time) && time >= state.forecastRangeStart && time <= state.forecastRangeEnd;
})
};
}).filter(function (entry) { return entry.points.length; });
var legend = byId("forecastLegend");
legend.innerHTML = series.map(function (entry) {
var prediction = entry.key.indexOf("prog_var_") === 0 ? " prediction" : "";
var activeSchedule = state.forecastResult && state.forecastResult.scheduleSourceModel === entry.key ? " · aktiv" : "";
return "<span class='forecast-chart-legend-item'><i class='" + prediction + "' style='--series-color:" + forecastSeriesColor(entry.key) + "'></i><span>" + escapeHtml(forecastSeriesLabel(entry.key) + activeSchedule) + "</span></span>";
}).join("");
legend.classList.toggle("hidden", !series.length);
byId("forecastSeriesCount").textContent = String(series.length);
if (!series.length) return setForecastState("Im gewählten Zeitfenster sind für die eingeblendeten Reihen keine Daten vorhanden.");
var allPoints = [];
series.forEach(function (entry) {
entry.points.forEach(function (point) {
var time = Date.parse(point.time);
var value = Number(point.value);
if (Number.isFinite(time) && Number.isFinite(value)) allPoints.push({ time: time, value: value });
});
});
if (!allPoints.length) return setForecastState("Im gewählten Zeitfenster sind keine darstellbaren Daten vorhanden.");
var width = 960;
var height = 320;
var padding = { left: 62, right: 24, top: 22, bottom: 42 };
var minTime = state.forecastRangeStart;
var maxTime = state.forecastRangeEnd;
var minValue = Math.min(0, Math.min.apply(null, allPoints.map(function (point) { return point.value; })));
var maxValue = Math.max(0, Math.max.apply(null, allPoints.map(function (point) { return point.value; })));
if (maxTime === minTime) maxTime += 1;
if (maxValue === minValue) maxValue += 1;
var x = function (time) { return padding.left + (time - minTime) / (maxTime - minTime) * (width - padding.left - padding.right); };
var y = function (value) { return padding.top + (maxValue - value) / (maxValue - minValue) * (height - padding.top - padding.bottom); };
var svg = [];
for (var grid = 0; grid <= 4; grid += 1) {
var gy = padding.top + grid / 4 * (height - padding.top - padding.bottom);
var label = maxValue - grid / 4 * (maxValue - minValue);
svg.push("<line class='forecast-grid-line' x1='" + padding.left + "' y1='" + gy + "' x2='" + (width - padding.right) + "' y2='" + gy + "' />");
svg.push("<text class='forecast-axis-label' x='" + (padding.left - 10) + "' y='" + (gy + 4) + "' text-anchor='end'>" + label.toFixed(1) + "</text>");
}
var now = Date.now();
if (now >= minTime && now <= maxTime) {
var nowX = x(now);
svg.push("<line class='forecast-now-line' x1='" + nowX + "' y1='" + padding.top + "' x2='" + nowX + "' y2='" + (height - padding.bottom) + "' />");
svg.push("<text class='forecast-now-label' x='" + (nowX + 6) + "' y='" + (padding.top + 13) + "'>Jetzt</text>");
}
series.forEach(function (entry) {
var coordinates = entry.points.map(function (point) {
return { time: Date.parse(point.time), value: Number(point.value) };
}).filter(function (point) {
return Number.isFinite(point.time) && Number.isFinite(point.value);
}).sort(function (a, b) { return a.time - b.time; }).map(function (point) {
return x(point.time).toFixed(2) + "," + y(point.value).toFixed(2);
}).join(" ");
var lineClass = entry.key.indexOf("prog_var_") === 0
? "forecast-series-line forecast-series-prediction"
: "forecast-series-line";
svg.push("<polyline class='" + lineClass + "' vector-effect='non-scaling-stroke' fill='none' stroke='" + forecastSeriesColor(entry.key) + "' points='" + coordinates + "' />");
});
svg.push("<line id='forecastHoverLine' class='forecast-hover-line hidden' x1='0' y1='" + padding.top + "' x2='0' y2='" + (height - padding.bottom) + "' />");
svg.push("<text class='forecast-axis-label' x='" + padding.left + "' y='" + (height - 12) + "'>" + formatForecastDate(minTime) + "</text>");
svg.push("<text class='forecast-axis-label' x='" + (width - padding.right) + "' y='" + (height - 12) + "' text-anchor='end'>" + formatForecastDate(maxTime) + "</text>");
byId("forecastChart").innerHTML = svg.join("");
byId("forecastState").classList.add("hidden");
byId("forecastChart").classList.remove("hidden");
attachForecastHover(series, minTime, maxTime, width, height, padding);
}
var forecastCapabilityVariants = {
forecast_pv: [1, 10, 21],
forecast_load: [2, 11, 22],
grid_schedule: [3, 13, 23]
};
var customTariff = "Preis selber eingeben";
var forecastTariffs = {
import: {
dynamisch: [
{ id: "CKW Dynamisch Home", label: "CKW Netz Home dynamic (ab 2027)", fallback: 0.2467 },
{ id: "CKW Dynamisch Business", label: "CKW Netz Business dynamic (ab 2027)", fallback: 0.2467 },
{ id: "BKW Sonne scheint", label: "BKW Sonne scheint (ab 2027)", fallback: 0.256 },
{ id: "EWN MarktStrom Spot", label: "EWN MarktStrom Spot", fallback: 0.1715 },
{ id: "EWO MarktStrom Spot", label: "EWO MarktStrom Spot", fallback: 0.2524 }
],
statisch: [
{ id: "CKW ClassicStrom 2027", price: 0.2467 },
{ id: "BKW Energy Blue 2027", price: 0.256 },
{ id: "EWN StandardStrom 50 Sommer 2027", price: 0.1715 },
{ id: "EWN StandardStrom 50 Winter 2027", price: 0.1915 },
{ id: "EWN NaturStrom 50 Sommer 2027", price: 0.1815 },
{ id: "EWN NaturStrom 50 Winter 2027", price: 0.2015 },
{ id: "EWO NaturStrom Sommer Normal 2026", price: 0.2524 },
{ id: "EWO NaturStrom Sommer Spar 2026", price: 0.2162 },
{ id: "EWO NaturStrom Winter Normal 2026", price: 0.2851 },
{ id: "EWO NaturStrom Winter Spar 2026", price: 0.2489 },
{ id: customTariff, custom: true }
]
},
export: {
dynamisch: [
{ id: "CKW Spotmarkt Einspeisung 2027", fallback: 0.06 },
{ id: "BKW Dynamische Abnahmevergütung 2027", fallback: 0.06 },
{ id: "EWN Referenzmarktpreis BFE", fallback: 0.06 },
{ id: "EWO Referenzmarktpreis BFE", fallback: 0.06 },
{ id: "Referenzmarktpreis Europa", fallback: 0.06 }
],
statisch: [
{ id: "Mindestvergütung bis 30 kWp 2027", price: 0.06 },
{ id: customTariff, custom: true }
]
}
};
function tariffOptions(direction, mode) {
return (forecastTariffs[direction] && forecastTariffs[direction][mode]) || [];
}
function tariffMode(direction, selected, configured) {
if (configured === "dynamisch" || configured === "statisch") return configured;
return tariffOptions(direction, "dynamisch").some(function (item) { return item.id === selected; })
? "dynamisch" : "statisch";
}
function renderTariffSelector(form, direction, configuredMode, configuredTariff, configuredPrice, preserveConfiguredPrice) {
var prefix = direction === "import" ? "bezug" : "einspeisung";
var mode = tariffMode(direction, configuredTariff, configuredMode);
var modeField = form.elements["tarif_" + prefix + "_modus"];
var tariffField = form.elements["tarif_" + prefix];
var priceField = form.elements["tarif_" + prefix + "_fest"];
var options = tariffOptions(direction, mode);
var selected = options.find(function (item) { return item.id === configuredTariff; }) || options[0];
modeField.value = mode;
tariffField.innerHTML = options.map(function (item) {
return "<option value='" + escapeHtml(item.id) + "'>" + escapeHtml(item.label || item.id) + "</option>";
}).join("");
tariffField.value = selected.id;
var configuredNumber = Number(configuredPrice);
var catalogPrice = selected.price === undefined ? selected.fallback : selected.price;
var price = preserveConfiguredPrice && Number.isFinite(configuredNumber)
? configuredNumber
: (catalogPrice === undefined ? configuredPrice : catalogPrice);
priceField.value = forecastNumber(price, direction === "import" ? 0.3 : 0.1);
var priceLabel = form.querySelector("[data-tariff-custom='" + direction + "']");
var usesFixedPrice = mode === "statisch";
priceLabel.classList.toggle("hidden", !usesFixedPrice);
priceField.disabled = !usesFixedPrice;
priceField.required = usesFixedPrice;
}
function refreshTariffSelector(form, direction) {
var prefix = direction === "import" ? "bezug" : "einspeisung";
renderTariffSelector(
form,
direction,
form.elements["tarif_" + prefix + "_modus"].value,
form.elements["tarif_" + prefix].value,
form.elements["tarif_" + prefix + "_fest"].value,
false
);
}
function forecastNumber(value, fallback) {
var number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function topologyNumber(value, unit) {
return forecastNumber(value, 0).toLocaleString("de-CH", { maximumFractionDigits: 3 }) + " " + unit;
}
function topologyRows(title, items, describe) {
return "<section class='forecast-topology-group'><h4>" + escapeHtml(title) + "</h4>"
+ (items.length ? items.map(function (item) {
return "<div><strong>" + escapeHtml(item.name || item.id) + "</strong><span>"
+ escapeHtml(describe(item)) + "</span></div>";
}).join("") : "<p>Keine Komponenten</p>")
+ "</section>";
}
function renderForecastTopology(topology) {
var block = byId("forecastTopologyBlock");
if (!topology) {
block.classList.add("hidden");
byId("forecastTopology").innerHTML = "";
return;
}
var summary = topology.summary || {};
var inverterTypes = { pv: "PV", battery: "Batterie", hybrid: "Hybrid" };
var couplingTypes = { ac: "AC-gekoppelt", dc: "DC-gekoppelt", hybrid: "Hybrid" };
byId("forecastTopology").innerHTML =
"<div class='forecast-topology-summary'>"
+ "<div><span>PV DC</span><strong>" + topologyNumber(summary.pvDcKwp, "kWp") + "</strong></div>"
+ "<div><span>PV AC</span><strong>" + topologyNumber(summary.pvAcKw, "kW") + "</strong></div>"
+ "<div><span>Batterie nutzbar</span><strong>" + topologyNumber(summary.usableBatteryKwh, "kWh") + "</strong></div>"
+ "<div><span>Laden</span><strong>" + topologyNumber(summary.maxBatteryChargeKw, "kW") + "</strong></div>"
+ "<div><span>Entladen</span><strong>" + topologyNumber(summary.maxBatteryDischargeKw, "kW") + "</strong></div>"
+ "</div><div class='forecast-topology-groups'>"
+ topologyRows("Wechselrichter", topology.inverters || [], function (item) {
return (inverterTypes[item.type] || item.type) + " · " + topologyNumber(item.acRatedKw, "kW AC");
})
+ topologyRows("PV-Flächen", topology.pvArrays || [], function (item) {
return topologyNumber(item.dcKwp, "kWp") + " · " + topologyNumber(item.tiltDeg, "°") + " · Azimut " + forecastNumber(item.azimuthDeg, 0);
})
+ topologyRows("Batterien", topology.batteries || [], function (item) {
return topologyNumber(item.usableCapacityKwh, "kWh") + " · " + (couplingTypes[item.coupling] || item.coupling);
})
+ "</div>";
block.classList.remove("hidden");
}
function renderForecastRoofs(roofs, readOnly) {
var disabled = readOnly ? " disabled" : "";
byId("forecastRoofs").innerHTML = (roofs || []).map(function (roof, index) {
return "<div class='forecast-roof-row' data-roof-index='" + index + "'>"
+ "<label>Leistung kWp<input data-roof-field='kwp' type='number' min='0.1' max='100000' step='0.1' value='" + forecastNumber(roof.kwp, 0) + "' required" + disabled + "></label>"
+ "<label>Neigung °<input data-roof-field='neigung' type='number' min='0' max='90' step='1' value='" + forecastNumber(roof.neigung, 30) + "' required" + disabled + "></label>"
+ "<label>Azimut °<input data-roof-field='azimut' type='number' min='-180' max='360' step='1' value='" + forecastNumber(roof.azimut, 180) + "' required" + disabled + "></label>"
+ "<button class='icon-button' type='button' data-remove-roof aria-label='Dach entfernen'" + disabled + ">×</button>"
+ "</div>";
}).join("");
}
function fillForecastConfiguration(plant, result) {
var configuration = result.configuration || {};
var form = byId("forecastConfigForm");
form.dataset.plantId = plant.id;
form.elements.standort.value = configuration.standort || plant.location || "";
form.elements.kanton.value = configuration.kanton || "LU";
form.elements.ac_leistung.value = forecastNumber(configuration.ac_leistung, 10);
form.elements.batt_capacity_kwh.value = forecastNumber(configuration.batt_capacity_kwh, 0);
form.elements.batt_power_kw.value = forecastNumber(configuration.batt_power_kw, 0);
form.elements.tarif_peak_fest.value = forecastNumber(configuration.tarif_peak_fest, 5);
renderTariffSelector(form, "import", configuration.tarif_bezug_modus, configuration.tarif_bezug, configuration.tarif_bezug_fest, true);
renderTariffSelector(form, "export", configuration.tarif_einspeisung_modus, configuration.tarif_einspeisung, configuration.tarif_einspeisung_fest, true);
Object.entries(forecastCapabilityVariants).forEach(function (entry) {
var capability = entry[0];
var variants = entry[1];
form.elements[capability].checked = variants.some(function (variant) {
return Boolean(configuration["prog_var_" + variant]);
});
});
var managerOwned = result.technicalSource === "manager" && Boolean(result.topology);
renderForecastRoofs(configuration.daecher || [], managerOwned);
renderForecastTopology(result.topology || null);
var canManage = state.session.organization.type === "personal"
|| ["owner", "admin", "operator"].includes(state.session.organization.role);
form.querySelectorAll("input, select, button").forEach(function (control) {
control.disabled = !canManage;
});
if (managerOwned) {
form.querySelectorAll("[data-technical-field]").forEach(function (control) {
control.disabled = true;
});
byId("forecastAddRoof").disabled = true;
byId("forecastRoofs").querySelectorAll("input, button").forEach(function (control) {
control.disabled = true;
});
}
var licensed = result.forecastCapabilities || {
pv: true,
load: true,
schedule: true
};
[
["forecast_pv", "pv"],
["forecast_load", "load"],
["grid_schedule", "schedule"]
].forEach(function (entry) {
if (!licensed[entry[1]]) {
form.elements[entry[0]].checked = false;
form.elements[entry[0]].disabled = true;
}
});
byId("forecastConfigStatus").textContent = managerOwned
? "Technik vom Manager"
: (result.configured ? "Gespeichert" : "Noch nicht gespeichert");
byId("forecastConfigStatus").className = "status " + (result.configured ? "available" : "review");
byId("forecastConfigSection").classList.remove("hidden");
}
async function loadForecastConfiguration(plant, requestId) {
try {
var result = await api("/api/plants/" + plant.id + "/prognosis/configuration");
if (requestId !== state.forecastRequest) return;
fillForecastConfiguration(plant, result);
} catch (error) {
if (requestId !== state.forecastRequest) return;
byId("forecastConfigError").textContent = error.message;
byId("forecastConfigSection").classList.remove("hidden");
}
}
function forecastConfigurationPayload(form) {
var selected = {
forecast_pv: form.elements.forecast_pv.checked,
forecast_load: form.elements.forecast_load.checked,
grid_schedule: form.elements.grid_schedule.checked
};
if (selected.grid_schedule) {
selected.forecast_pv = true;
selected.forecast_load = true;
}
if (!Object.values(selected).some(Boolean)) {
throw new Error("Bitte mindestens eine Prognosefunktion aktivieren.");
}
var variants = {};
Object.entries(forecastCapabilityVariants).forEach(function (entry) {
entry[1].forEach(function (variant) {
variants["prog_var_" + variant] = selected[entry[0]];
});
});
var roofs = Array.from(byId("forecastRoofs").querySelectorAll(".forecast-roof-row")).map(function (row) {
return {
kwp: Number(row.querySelector("[data-roof-field='kwp']").value),
neigung: Number(row.querySelector("[data-roof-field='neigung']").value),
azimut: Number(row.querySelector("[data-roof-field='azimut']").value)
};
});
return Object.assign({
standort: form.elements.standort.value,
kanton: form.elements.kanton.value,
ac_leistung: Number(form.elements.ac_leistung.value),
batt_capacity_kwh: Number(form.elements.batt_capacity_kwh.value),
batt_power_kw: Number(form.elements.batt_power_kw.value),
tarif_bezug_modus: form.elements.tarif_bezug_modus.value,
tarif_bezug: form.elements.tarif_bezug.value,
tarif_bezug_fest: Number(form.elements.tarif_bezug_fest.value),
tarif_einspeisung_modus: form.elements.tarif_einspeisung_modus.value,
tarif_einspeisung: form.elements.tarif_einspeisung.value,
tarif_einspeisung_fest: Number(form.elements.tarif_einspeisung_fest.value),
tarif_peak: "Festwert",
tarif_peak_fest: Number(form.elements.tarif_peak_fest.value),
daecher: roofs
}, variants);
}
var integratedPlanner = null;
async function loadIntegratedPlanner(plant, requestId) {
var host = byId("forecastPlanner");
if (!host || !plant.installation_id) return;
host.textContent = "Aktueller Netzfahrplan wird geladen.";
try {
var plannerModule = await import("/netplan-v4.js?v=20261006-integrated");
if (requestId !== state.forecastRequest || !byId("view-forecast").classList.contains("active")) return;
integratedPlanner = plannerModule.mountPlanner(host, { plantId: plant.id, session: state.session });
await integratedPlanner.ready;
} catch (error) {
if (requestId === state.forecastRequest) host.textContent = "Netzfahrplan nicht verfuegbar: " + error.message;
}
}
async function updateForecast() {
if (integratedPlanner) integratedPlanner.dispose();
integratedPlanner = null;
if (byId("forecastPlanner")) byId("forecastPlanner").replaceChildren();
var plant = selectedPlant("forecastPlant");
var requestId = ++state.forecastRequest;
if (!plant || state.forecastPlantId !== plant.id) {
state.forecastPlantId = plant ? plant.id : null;
state.forecastResult = null;
state.forecastVisibleSeries = null;
state.forecastRangeStart = null;
state.forecastRangeEnd = null;
}
resetForecastChart();
if (!plant) return setForecastState("Kein System verfügbar.");
byId("forecastVariant").textContent = managerLabel(plant.manager_variant);
byId("forecastConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft";
renderForecastLicenseWarning(plant);
if (!byId("view-forecast").classList.contains("active")) return;
void loadIntegratedPlanner(plant, requestId);
setForecastState("Prognosedaten werden geladen.");
byId("forecastApiStatus").textContent = "Datenverbindung wird geprüft";
try {
var result = await api("/api/plants/" + plant.id + "/prognosis?historyDays=" + encodeURIComponent(state.forecastHistoryDays));
if (requestId !== state.forecastRequest) return;
renderForecastResult(result);
if (["available", "no_data"].includes(result.status)) {
await loadForecastConfiguration(plant, requestId);
}
} catch (error) {
if (requestId !== state.forecastRequest) return;
byId("forecastApiStatus").textContent = "Prognosedienst nicht erreichbar";
setForecastState(error.message);
}
}
function faultDate(value) {
if (!value) return "–";
var parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "–" : parsed.toLocaleString("de-CH");
}
function faultSeverityLabel(value) {
return { critical: "Kritisch", error: "Fehler", warning: "Warnung", info: "Info" }[value] || value;
}
function resetFaultDisplay(message) {
byId("faultActiveCount").textContent = "0";
byId("faultUrgentCount").textContent = "0";
byId("faultLastUpdated").textContent = "–";
byId("faultActiveBody").innerHTML = "";
byId("faultHistoryBody").innerHTML = "";
byId("faultActiveTable").classList.add("hidden");
byId("faultHistoryTable").classList.add("hidden");
byId("faultState").classList.remove("hidden");
byId("faultState").textContent = message;
byId("faultHistoryState").classList.remove("hidden");
byId("faultHistoryState").textContent = "Noch keine behobenen Störungen.";
}
function resetFaultRecipients(message) {
byId("faultRecipientForm").classList.add("hidden");
byId("faultRecipientError").textContent = "";
byId("faultRecipientList").innerHTML = "";
byId("faultRecipientStatus").textContent = message;
}
function renderFaultRecipients(result) {
var recipients = result.recipients || [];
byId("faultRecipientForm").classList.toggle("hidden", !result.canManage);
byId("faultRecipientError").textContent = "";
byId("faultRecipientList").innerHTML = recipients.length
? recipients.map(function (recipient) {
var remove = result.canManage
? "<button class='icon-button' type='button' data-remove-fault-recipient='" + escapeHtml(recipient.email) +
"' aria-label='" + escapeHtml(recipient.email) + " entfernen' title='Empfänger entfernen'>×</button>"
: "";
return "<div class='fault-recipient-row'><span><strong>" + escapeHtml(recipient.email) +
"</strong><small>Seit " + escapeHtml(faultDate(recipient.createdAt)) + "</small></span>" + remove + "</div>";
}).join("")
: "<div class='fault-recipient-empty'>Noch keine Empfänger eingetragen.</div>";
byId("faultRecipientStatus").textContent = result.mailConfigured
? recipients.length + " von " + Number(result.limit || 10) + " Empfängern eingetragen."
: "Der Mailversand ist serverseitig noch nicht konfiguriert.";
}
function faultRows(faults, history) {
return faults.map(function (fault) {
var source = "<strong>" + escapeHtml(fault.sourceName) + "</strong><small>" + escapeHtml(fault.sourceType + " · " + fault.sourceId) + "</small>";
var message = "<strong>" + escapeHtml(fault.message) + "</strong><small>" + escapeHtml(fault.code) + "</small>";
var time = history ? fault.resolvedAt : fault.firstSeenAt;
return "<tr><td><span class='severity severity-" + escapeHtml(fault.severity) + "'>" +
escapeHtml(faultSeverityLabel(fault.severity)) + "</span></td><td>" + source +
"</td><td>" + message + "</td><td>" + escapeHtml(faultDate(time)) + "</td></tr>";
}).join("");
}
function renderFaultResult(result, plant) {
var active = result.active || [];
var history = result.history || [];
var labels = {
available: ["Aktuell", "available", "Störungsdaten sind aktuell."],
offline: ["Offline", "review", "Seit mehr als 15 Minuten keine Daten empfangen."],
no_data: ["Wartet auf Daten", "neutral", "Der Manager hat noch keinen Störungssnapshot gesendet."],
not_connected: ["Nicht verbunden", "review", "Das System ist noch nicht mit einem Manager verknüpft."],
license_required: ["Managerlizenz erforderlich", "review", "Für dieses System ist noch keine aktive Managerlizenz vorhanden."]
};
var status = labels[result.status] || ["Unbekannt", "review", "Der Störungsstatus konnte nicht bestimmt werden."];
var managerInactive = result.manager && !result.manager.active;
byId("faultConnectionStatus").textContent = managerInactive ? "Manager inaktiv" : status[0];
byId("faultConnectionStatus").className = "status " + (managerInactive ? "review" : status[1]);
byId("faultConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft";
byId("faultApiStatus").textContent = managerInactive ? "Der Manager meldet sich als inaktiv." : status[2];
byId("faultSignal").classList.toggle("offline", result.status !== "available" || managerInactive);
byId("faultActiveCount").textContent = String(active.length);
byId("faultUrgentCount").textContent = String(active.filter(function (fault) { return ["critical", "error"].includes(fault.severity); }).length);
byId("faultLastUpdated").textContent = faultDate(result.lastReceivedAt);
byId("faultActiveBody").innerHTML = faultRows(active, false);
byId("faultActiveTable").classList.toggle("hidden", active.length === 0);
byId("faultState").classList.toggle("hidden", active.length > 0);
byId("faultState").textContent = result.status === "available" && active.length === 0
? "Keine aktiven Störungen."
: status[2];
byId("faultHistoryBody").innerHTML = faultRows(history, true);
byId("faultHistoryTable").classList.toggle("hidden", history.length === 0);
byId("faultHistoryState").classList.toggle("hidden", history.length > 0);
byId("faultHistoryState").textContent = "Noch keine behobenen Störungen.";
}
async function updateFaults() {
var plant = selectedPlant("faultPlant");
var requestId = ++state.faultRequest;
resetFaultDisplay("Störungsdaten werden geladen.");
resetFaultRecipients("Empfänger werden geladen.");
if (!plant) {
byId("faultConnectionStatus").textContent = "Kein System";
byId("faultConnectionStatus").className = "status neutral";
resetFaultRecipients("Für die Empfängerverwaltung muss eine Anlage gewählt sein.");
return;
}
byId("faultConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft";
if (!byId("view-faults").classList.contains("active")) return;
byId("faultApiStatus").textContent = "Datenverbindung wird geprüft";
var responses = await Promise.allSettled([
api("/api/plants/" + plant.id + "/faults"),
api("/api/plants/" + plant.id + "/fault-notification-recipients")
]);
if (requestId !== state.faultRequest) return;
if (responses[0].status === "fulfilled") {
renderFaultResult(responses[0].value, plant);
} else {
byId("faultConnectionStatus").textContent = "Nicht erreichbar";
byId("faultConnectionStatus").className = "status review";
byId("faultApiStatus").textContent = "Störungsdienst nicht erreichbar";
byId("faultSignal").classList.add("offline");
resetFaultDisplay(responses[0].reason.message);
}
if (responses[1].status === "fulfilled") {
renderFaultRecipients(responses[1].value);
} else {
resetFaultRecipients(responses[1].reason.message);
}
}
function configurePlantLegacy(plant) {
if (!plant) return;
var owned = paidLicenseSummary(plant.id);
var configuration = null;
try { configuration = plant.configuration_json ? JSON.parse(plant.configuration_json) : null; } catch (_) { configuration = null; }
configuration = configuration || {
name: plant.name,
location: plant.location,
siteType: plant.type,
peakShaving: currentManagerVariant(plant) === "manager_peak",
gridMeasurement: { manufacturer: "", host: "", source: "", notes: "" },
pvSystem: { enabled: false, manufacturer: "", host: "" },
costReport: { enabled: false, powerMeters: 0, auxiliaryMeters: 0 },
devices: {
battery: { quantity: owned.battery || 0, name: "" },
buffer_storage: { quantity: owned.buffer_storage || 0, name: "" },
ev_charger: { quantity: owned.ev_charger || 0, model: "", address: "" },
consumer_single: { quantity: owned.consumer_single || 0, name: "", ratedPower: 0 },
boiler_multi: { quantity: owned.boiler_multi || 0, name: "", volume: 0 }
}
};
configuration.devices = configuration.devices || {};
if (!Object.prototype.hasOwnProperty.call(configuration.devices, "buffer_storage")) {
configuration.devices.buffer_storage = configuration.devices.battery || {};
configuration.devices.battery = {};
}
Object.keys(deviceLabels).forEach(function (key) {
configuration.devices[key] = configuration.devices[key] || {};
configuration.devices[key].quantity = Math.max(Number(configuration.devices[key].quantity || 0), Number(owned[key] || 0));
});
var form = byId("systemForm");
form.dataset.plantId = plant.id;
form.elements.name.value = configuration.name || plant.name;
form.elements.location.value = configuration.location || plant.location;
form.elements.siteType.value = configuration.siteType || plant.type;
form.elements.peakShaving.checked = Boolean(configuration.peakShaving);
form.elements.gridManufacturer.value = configuration.gridMeasurement?.manufacturer || "";
form.elements.gridHost.value = configuration.gridMeasurement?.host || "";
form.elements.gridSource.value = configuration.gridMeasurement?.source || "";
form.elements.gridNotes.value = configuration.gridMeasurement?.notes || "";
form.elements.pvEnabled.checked = Boolean(configuration.pvSystem?.enabled);
form.elements.pvManufacturer.value = configuration.pvSystem?.manufacturer || "";
form.elements.pvHost.value = configuration.pvSystem?.host || "";
form.elements.batteryQuantity.value = configuration.devices?.battery?.quantity || 0;
form.elements.batteryName.value = configuration.devices?.battery?.name || "";
form.elements.bufferQuantity.value = configuration.devices?.buffer_storage?.quantity || 0;
form.elements.bufferName.value = configuration.devices?.buffer_storage?.name || "";
form.elements.evQuantity.value = configuration.devices?.ev_charger?.quantity || 0;
form.elements.evModel.value = configuration.devices?.ev_charger?.model || "";
form.elements.evAddress.value = configuration.devices?.ev_charger?.address || "";
form.elements.consumerQuantity.value = configuration.devices?.consumer_single?.quantity || 0;
form.elements.consumerName.value = configuration.devices?.consumer_single?.name || "";
form.elements.consumerPower.value = configuration.devices?.consumer_single?.ratedPower || 0;
form.elements.consumerHardware.value = configuration.devices?.consumer_single?.hardware || "";
form.elements.consumerTopic.value = configuration.devices?.consumer_single?.topicPrefix || "";
form.elements.boilerQuantity.value = configuration.devices?.boiler_multi?.quantity || 0;
form.elements.boilerName.value = configuration.devices?.boiler_multi?.name || "";
form.elements.boilerVolume.value = configuration.devices?.boiler_multi?.volume || 0;
form.elements.boilerHardware.value = configuration.devices?.boiler_multi?.hardware || "";
form.elements.boilerTopic.value = configuration.devices?.boiler_multi?.topicPrefix || "";
form.elements.costReportEnabled.checked = Boolean(configuration.costReport?.enabled);
form.elements.costPowerMeters.value = configuration.costReport?.powerMeters || 0;
form.elements.costAuxMeters.value = configuration.costReport?.auxiliaryMeters || 0;
byId("packageResult").classList.add("hidden");
showWizardStep(0);
updateRecommendation();
showView("configurator");
}
function systemPayloadLegacy(form) {
var values = Object.fromEntries(new FormData(form).entries());
return {
plantId: form.dataset.plantId || "",
name: values.name, location: values.location, siteType: values.siteType, peakShaving: Boolean(values.peakShaving),
gridMeasurement: { manufacturer: values.gridManufacturer, host: values.gridHost, source: values.gridSource, notes: values.gridNotes },
pvSystem: { enabled: Boolean(values.pvEnabled), manufacturer: values.pvManufacturer, host: values.pvHost },
costReport: { enabled: Boolean(values.costReportEnabled), powerMeters: Number(values.costPowerMeters || 0), auxiliaryMeters: Number(values.costAuxMeters || 0) },
devices: {
battery: { quantity: Number(values.batteryQuantity), name: values.batteryName },
buffer_storage: { quantity: Number(values.bufferQuantity), name: values.bufferName },
ev_charger: { quantity: Number(values.evQuantity), name: "Ladestation", model: values.evModel, address: values.evAddress },
consumer_single: { quantity: Number(values.consumerQuantity), name: values.consumerName, ratedPower: Number(values.consumerPower || 0), hardware: values.consumerHardware, topicPrefix: values.consumerTopic },
boiler_multi: { quantity: Number(values.boilerQuantity), name: values.boilerName, volume: Number(values.boilerVolume || 0), hardware: values.boilerHardware, topicPrefix: values.boilerTopic }
}
};
}
function schemaDefaults(fields) {
return Object.fromEntries((fields || []).map(function (field) { return [field.key, field.default]; }));
}
function renderSchemaField(field, value, attribute) {
var data = " " + attribute + "='" + escapeHtml(field.key) + "'";
if (field.type === "boolean") {
return "<label class='schema-toggle'><input type='checkbox'" + data + (value ? " checked" : "") + "><span>" + escapeHtml(field.label) + "</span></label>";
}
if (field.type === "select" || field.type === "choice") {
var options = (field.options || []).map(function (option) {
return "<option value='" + escapeHtml(option.value) + "'" + (String(option.value) === String(value) ? " selected" : "") + ">" + escapeHtml(option.label) + "</option>";
}).join("");
return "<label>" + escapeHtml(field.label) + "<select" + data + ">" + options + "</select></label>";
}
if (field.type === "json") {
return "<label class='full-width'>" + escapeHtml(field.label) + "<textarea rows='4'" + data + ">" + escapeHtml(value == null ? field.default : value) + "</textarea></label>";
}
var type = ["integer", "number"].includes(field.type) ? "number" : "text";
var step = field.type === "number" ? " step='any'" : "";
var min = field.min == null ? "" : " min='" + escapeHtml(field.min) + "'";
var max = field.max == null ? "" : " max='" + escapeHtml(field.max) + "'";
return "<label>" + escapeHtml(field.label) + "<input type='" + type + "'" + step + min + max + data + " value='" + escapeHtml(value == null ? field.default : value) + "'></label>";
}
function renderSchemaGroups(fields, values, attribute) {
var groups = {};
(fields || []).forEach(function (field) {
groups[field.section] = groups[field.section] || [];
groups[field.section].push(field);
});
return Object.keys(groups).map(function (section) {
var controls = groups[section].map(function (field) {
return renderSchemaField(field, Object.prototype.hasOwnProperty.call(values || {}, field.key) ? values[field.key] : field.default, attribute);
}).join("");
return "<section class='schema-group'><h4>" + escapeHtml(section) + "</h4><div class='schema-grid'>" + controls + "</div></section>";
}).join("");
}
function readSchemaValues(root, fields, attribute) {
var result = {};
(fields || []).forEach(function (field) {
var control = root.querySelector("[" + attribute + "='" + field.key + "']");
if (!control) return;
if (field.type === "boolean") result[field.key] = control.checked;
else if (field.type === "integer" || field.type === "select") result[field.key] = Number(control.value || 0);
else if (field.type === "choice") result[field.key] = control.value;
else if (field.type === "number") result[field.key] = Number(control.value || 0);
else result[field.key] = control.value;
});
return result;
}
function profileOptions(kind, selected) {
return (state.wizardCatalog?.integrations?.[kind] || []).map(function (profile) {
var label = profile.manufacturer + " · " + profile.name;
return "<option value='" + escapeHtml(profile.id) + "'" + (profile.id === selected ? " selected" : "") + ">" + escapeHtml(label) + "</option>";
}).join("");
}
function selectedProfile(kind, id) {
var profiles = state.wizardCatalog?.integrations?.[kind] || [];
return profiles.find(function (entry) { return entry.id === id; }) || profiles[0] || {};
}
function integrationKind(key) {
if (key === "battery") return "storage";
if (["buffer_storage", "consumer_single", "boiler_multi"].includes(key)) return "actuator";
return "";
}
function renderIntegrationFields(key, integration, compact) {
var kind = integrationKind(key);
if (!kind) return "";
var selected = selectedProfile(kind, integration.profile || "manual");
if (kind === "storage") {
var manualStorage = selected.id === "manual"
? "<label>Bestehende Leistungsvariable<input data-integration-field='sourceVariableId' type='number' min='0' value='" + escapeHtml(integration.sourceVariableId || 0) + "'></label>"
: "";
return "<div class='configuration-band' data-main-integration><h3>Speicheranbindung</h3><div class='field-grid'>" +
"<label>Geräteprofil<select data-integration-field='profile'>" + profileOptions(kind, selected.id) + "</select></label>" +
"<label>IP-Adresse oder Hostname<input data-integration-field='host' maxlength='120' value='" + escapeHtml(integration.host || "") + "'></label>" +
"<label>Port<input data-integration-field='port' type='number' min='0' max='65535' value='" + escapeHtml(integration.port || selected.defaultPort || 0) + "'></label>" +
"<label>Modbus Geräte-ID<input data-integration-field='unitId' type='number' min='0' max='247' value='" + escapeHtml(integration.unitId == null ? 1 : integration.unitId) + "'></label>" +
manualStorage + "</div><p class='integration-status'>Batterietyp und Variablen werden aus dem Profil abgeleitet.</p></div>";
}
var manual = selected.id === "manual"
? "<label>Bestehender Schaltkontakt<input data-integration-field='sourceVariableId' type='number' min='0' value='" + escapeHtml(integration.sourceVariableId || 0) + "'></label>" +
(key === "consumer_single" ? "<label>Bestehende Rückmeldung<input data-integration-field='feedbackVariableId' type='number' min='0' value='" + escapeHtml(integration.feedbackVariableId || 0) + "'></label>" : "")
: "";
return "<div class='configuration-band' data-main-integration><h3>" + (compact ? "Aktor" : "Schaltaktor") + "</h3><div class='field-grid'>" +
"<label>Geräteprofil<select data-integration-field='profile'>" + profileOptions(kind, selected.id) + "</select></label>" +
"<label>MQTT-Topic-Präfix<input data-integration-field='topicPrefix' maxlength='180' value='" + escapeHtml(integration.topicPrefix || "") + "'></label>" +
"<label>Ausgang / Kanal<input data-integration-field='channel' type='number' min='0' max='" + escapeHtml(Math.max(0, Number(selected.outputs || 1) - 1)) + "' value='" + escapeHtml(integration.channel || 0) + "'></label>" +
manual + "</div><p class='integration-status'>Schaltkontakt und Rückmeldung werden bei Geräteprofilen automatisch verbunden.</p></div>";
}
function clientLegacyItem(key, source, index) {
var properties = schemaDefaults(state.wizardCatalog?.consumerFields?.[key]);
var integration = { profile: "manual", host: "", port: 0, unitId: 1, topicPrefix: "", channel: 0, sourceVariableId: 0 };
if (key === "consumer_single") {
properties.Nennleistung = Number(source.ratedPower || 0);
if (source.hardware === "shelly_1pm") integration.profile = "shelly_plus_1pm";
integration.topicPrefix = source.topicPrefix || "";
}
if (key === "boiler_multi") {
properties.Boilervolumen = Number(source.volume || 300);
if (source.hardware === "shelly_1pm") integration.profile = "shelly_plus_1pm";
integration.topicPrefix = source.topicPrefix || "";
}
if (key === "buffer_storage" && source.hardware === "shelly_1pm") {
integration.profile = "shelly_plus_1pm";
integration.topicPrefix = source.topicPrefix || "";
}
if (key === "ev_charger") {
properties.Geraetetyp = { goe_legacy: 1, goe_gemini: 2, smartme_pico: 3 }[source.model] || 0;
properties.Geraeteadresse = source.address || "";
}
return {
name: source.name || deviceLabels[key] + " " + (index + 1),
variant: key === "ev_charger" ? "standalone" : undefined,
integration: integration,
properties: properties,
topology: key === "battery" ? schemaDefaults(state.wizardCatalog?.topologyFields?.battery) : undefined,
powerLevels: ["buffer_storage", "boiler_multi"].includes(key) ? [] : undefined,
schedule: key === "boiler_multi" ? [] : undefined,
sensor: ["buffer_storage", "boiler_multi"].includes(key)
? { profile: "manual", topicPrefix: "", sourceVariableId: 0 }
: undefined
};
}
function collectIntegration(root) {
var integration = {};
var scope = root.querySelector("[data-main-integration]") || root;
scope.querySelectorAll("[data-integration-field]").forEach(function (control) {
var key = control.dataset.integrationField;
integration[key] = ["port", "unitId", "channel", "sourceVariableId", "feedbackVariableId"].includes(key) ? Number(control.value || 0) : control.value;
});
return integration;
}
function renderSensorFields(sensor) {
var selected = selectedProfile("sensor", sensor?.profile || "manual");
var source = selected.id === "manual"
? "<label>Bestehende Temperaturvariable<input data-integration-field='sourceVariableId' type='number' min='0' value='" + escapeHtml(sensor?.sourceVariableId || 0) + "'></label>"
: "<label>MQTT-Topic-Präfix<input data-integration-field='topicPrefix' maxlength='180' value='" + escapeHtml(sensor?.topicPrefix || "") + "'></label>";
return "<div class='configuration-band sensor-band' data-sensor-integration><h3>Temperaturfühler</h3><div class='field-grid'>" +
"<label>Fühlerprofil<select data-integration-field='profile'>" + profileOptions("sensor", selected.id) + "</select></label>" +
source + "</div><p class='integration-status'>Beim Shelly Plus Add-on wird die Temperaturvariable automatisch zugeordnet.</p></div>";
}
function renderPowerLevels(key, levels) {
var fields = state.wizardCatalog?.deviceListFields?.powerLevel || [];
var rows = (levels || []).map(function (level, index) {
var values = Object.assign(schemaDefaults(fields), level || {}, { Stufe: index + 1 });
return "<div class='structured-row' data-power-level><div class='structured-row-head'><strong>Stufe " + (index + 1) +
"</strong><button class='text-action destructive-link' type='button' data-remove-power-level>Entfernen</button></div><div class='schema-groups'>" +
renderSchemaGroups(fields, values, "data-power-level-field") + "</div>" +
renderIntegrationFields(key, level.integration || { profile: "manual" }, true) + "</div>";
}).join("");
return "<section class='structured-editor'><div class='structured-editor-head'><div><h3>Leistungsstufen</h3><p>Leistung und Aktor je Stufe.</p></div>" +
"<button class='button secondary compact' type='button' data-add-power-level>Stufe hinzufügen</button></div><div data-power-levels>" + rows + "</div></section>";
}
function renderScheduleRows(rows) {
var fields = state.wizardCatalog?.deviceListFields?.schedule || [];
var content = (rows || []).map(function (row, index) {
return "<div class='structured-row compact-row' data-schedule-row><div class='structured-row-head'><strong>Zeitpunkt " + (index + 1) +
"</strong><button class='text-action destructive-link' type='button' data-remove-schedule-row>Entfernen</button></div><div class='schema-groups'>" +
renderSchemaGroups(fields, Object.assign(schemaDefaults(fields), row || {}), "data-schedule-field") + "</div></div>";
}).join("");
return "<section class='structured-editor'><div class='structured-editor-head'><div><h3>Solltemperatur-Zeitplan</h3><p>Uhrzeiten und Solltemperaturen als Liste.</p></div>" +
"<button class='button secondary compact' type='button' data-add-schedule-row>Zeitpunkt hinzufügen</button></div><div data-schedule-rows>" + content + "</div></section>";
}
function collectPowerLevels(root) {
var fields = state.wizardCatalog?.deviceListFields?.powerLevel || [];
return Array.from(root.querySelectorAll("[data-power-level]")).map(function (row, index) {
return {
...readSchemaValues(row, fields, "data-power-level-field"),
Stufe: index + 1,
integration: collectIntegration(row)
};
});
}
function collectScheduleRows(root) {
var fields = state.wizardCatalog?.deviceListFields?.schedule || [];
return Array.from(root.querySelectorAll("[data-schedule-row]")).map(function (row) {
return readSchemaValues(row, fields, "data-schedule-field");
});
}
function collectDeviceItems(key) {
return Array.from(document.querySelectorAll("[data-device-editor='" + key + "'] [data-device-instance]")).map(function (root) {
var variant = root.querySelector("[data-ev-variant]")?.value || "standalone";
var fields = key === "ev_charger" && variant === "gateway"
? state.wizardCatalog?.evGatewayFields || []
: state.wizardCatalog?.consumerFields?.[key] || [];
var result = {
name: root.querySelector("[data-device-name]").value,
variant: variant,
integration: ["buffer_storage", "boiler_multi"].includes(key) ? { profile: "manual" } : collectIntegration(root),
properties: readSchemaValues(root, fields, "data-device-property")
};
if (key === "battery") {
result.topology = readSchemaValues(root, state.wizardCatalog?.topologyFields?.battery || [], "data-battery-topology");
}
if (["buffer_storage", "boiler_multi"].includes(key)) {
result.powerLevels = collectPowerLevels(root);
result.sensor = collectIntegration(root.querySelector("[data-sensor-integration]"));
}
if (key === "boiler_multi") result.schedule = collectScheduleRows(root);
return result;
});
}
function renderDeviceEditor(key, quantity, sourceItems) {
var editor = document.querySelector("[data-device-editor='" + key + "']");
if (!editor || !state.wizardCatalog) return;
var items = sourceItems || (editor.children.length ? collectDeviceItems(key) : state.deviceDrafts[key] || []);
while (items.length < quantity) items.push(clientLegacyItem(key, {}, items.length));
items = items.slice(0, quantity);
state.deviceDrafts[key] = items;
editor.innerHTML = items.map(function (item, index) {
var variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone";
var fields = key === "ev_charger" && variant === "gateway"
? state.wizardCatalog.evGatewayFields || []
: state.wizardCatalog.consumerFields[key] || [];
var properties = Object.assign(schemaDefaults(fields), item.properties || {});
var name = item.name || deviceLabels[key] + " " + (index + 1);
var identity = "<div class='field-grid'><label>Bezeichnung<input data-device-name maxlength='80' value='" +
escapeHtml(name) + "'></label>";
if (key === "ev_charger") {
identity += "<label>Anbindung<select data-ev-variant>" + (state.wizardCatalog.evVariants || []).map(function (option) {
return "<option value='" + escapeHtml(option.value) + "'" + (option.value === variant ? " selected" : "") + ">" + escapeHtml(option.label) + "</option>";
}).join("") + "</select></label>";
}
identity += "</div>";
var topology = key === "battery"
? "<div class='schema-groups topology-settings'>" + renderSchemaGroups(
state.wizardCatalog?.topologyFields?.battery || [],
Object.assign(schemaDefaults(state.wizardCatalog?.topologyFields?.battery), item.topology || {}),
"data-battery-topology"
) + "</div>"
: "";
var integration = ["battery", "consumer_single"].includes(key)
? renderIntegrationFields(key, item.integration || {})
: "";
var structured = ["buffer_storage", "boiler_multi"].includes(key)
? renderSensorFields(item.sensor || {}) + renderPowerLevels(key, item.powerLevels || [])
: "";
if (key === "boiler_multi") structured += renderScheduleRows(item.schedule || []);
return "<details class='device-instance' data-device-instance='" + index + "'" + (index === 0 ? " open" : "") + "><summary>" +
escapeHtml(name) + "</summary><div class='device-instance-body'>" + identity + integration + topology + structured +
"<div class='schema-groups'>" + renderSchemaGroups(fields, properties, "data-device-property") + "</div></div></details>";
}).join("");
}
function collectPvSurfaces() {
var fields = state.wizardCatalog?.topologyFields?.pvSurface || [];
return Array.from(document.querySelectorAll("[data-topology-editor='pvSurfaces'] [data-topology-instance]")).map(function (root, index) {
return {
ID: "pv-" + (index + 1),
...readSchemaValues(root, fields, "data-pv-surface")
};
});
}
function renderPvSurfaceEditor(quantity, sourceItems) {
var editor = document.querySelector("[data-topology-editor='pvSurfaces']");
if (!editor || !state.wizardCatalog) return;
var fields = state.wizardCatalog.topologyFields?.pvSurface || [];
var items = sourceItems || (editor.children.length ? collectPvSurfaces() : state.pvSurfaceDrafts || []);
while (items.length < quantity) {
items.push({ ID: "pv-" + (items.length + 1), ...schemaDefaults(fields), Name: "PV-Fläche " + (items.length + 1) });
}
items = items.slice(0, quantity);
state.pvSurfaceDrafts = items;
editor.innerHTML = items.map(function (item, index) {
var values = Object.assign(schemaDefaults(fields), item || {});
return "<details class='device-instance topology-instance' data-topology-instance='" + index + "'" + (index === 0 ? " open" : "") + "><summary>" +
escapeHtml(values.Name || "PV-Fläche " + (index + 1)) + "</summary><div class='device-instance-body schema-groups'>" +
renderSchemaGroups(fields, values, "data-pv-surface") + "</div></details>";
}).join("");
}
function renderManagerFields(properties) {
if (!state.wizardCatalog || !byId("managerConfigurationFields")) return;
byId("managerConfigurationFields").innerHTML = renderSchemaGroups(state.wizardCatalog.managerFields, properties || {}, "data-manager-property");
}
function applyWizardCatalog(catalog) {
state.wizardCatalog = catalog;
document.querySelectorAll("[data-profile-select='energy']").forEach(function (select) {
select.innerHTML = profileOptions("energy", "manual");
});
renderManagerFields({});
Object.keys(deviceLabels).forEach(function (key) { renderDeviceEditor(key, 0, []); });
renderPvSurfaceEditor(0, []);
updateEnergySourceVisibility(byId("systemForm"), "grid");
updateEnergySourceVisibility(byId("systemForm"), "pv");
updatePvTopologyVisibility(byId("systemForm"));
}
function updateEnergySourceVisibility(form, prefix) {
var manual = form.elements[prefix + "Profile"]?.value === "manual";
form.querySelector("[data-manual-source='" + prefix + "']")?.classList.toggle("hidden", !manual);
}
function updatePvTopologyVisibility(form) {
form.querySelector("[data-pv-topology]")?.classList.toggle("hidden", !form.elements.pvEnabled.checked);
}
function setEnergyIntegration(form, prefix, integration) {
var select = form.elements[prefix + "Profile"];
select.value = integration.profile || (integration.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual");
form.elements[prefix + "Host"].value = integration.host || "";
form.elements[prefix + "Port"].value = integration.port || selectedProfile("energy", select.value).defaultPort || 0;
form.elements[prefix + "UnitId"].value = integration.unitId == null ? 1 : integration.unitId;
if (form.elements[prefix + "SourceVariableId"]) {
form.elements[prefix + "SourceVariableId"].value = integration.sourceVariableId || 0;
}
updateEnergySourceVisibility(form, prefix);
}
function configurePlant(plant) {
if (!plant || !state.wizardCatalog) return;
var owned = paidLicenseSummary(plant.id);
var configuration = null;
try { configuration = plant.configuration_json ? JSON.parse(plant.configuration_json) : null; } catch (_) { configuration = null; }
configuration = configuration || {
name: plant.name, location: plant.location, siteType: plant.type,
peakShaving: currentManagerVariant(plant) === "manager_peak",
manager: { properties: {} },
gridMeasurement: { profile: "manual" },
pvSystem: { enabled: false, profile: "manual" },
costReport: { enabled: false, powerMeters: 0, auxiliaryMeters: 0 },
devices: {}
};
configuration.devices = configuration.devices || {};
if (!Object.prototype.hasOwnProperty.call(configuration.devices, "buffer_storage") && configuration.devices.battery) {
configuration.devices.buffer_storage = configuration.devices.battery;
configuration.devices.battery = {};
}
var form = byId("systemForm");
form.dataset.plantId = plant.id;
form.elements.name.value = configuration.name || plant.name;
form.elements.location.value = configuration.location || plant.location;
form.elements.siteType.value = configuration.siteType || plant.type;
form.elements.peakShaving.checked = Boolean(configuration.peakShaving);
setEnergyIntegration(form, "grid", configuration.gridMeasurement || {});
form.elements.gridSourceVariableId.value = configuration.gridMeasurement?.sourceVariableId || Number(configuration.gridMeasurement?.source || 0);
form.elements.gridNotes.value = configuration.gridMeasurement?.notes || "";
form.elements.pvEnabled.checked = Boolean(configuration.pvSystem?.enabled);
setEnergyIntegration(form, "pv", configuration.pvSystem || {});
form.elements.pvName.value = configuration.pvSystem?.name || "PV-Wechselrichter";
form.elements.pvAcPowerKw.value = configuration.pvSystem?.acPowerKw || 0;
var pvSurfaces = Array.isArray(configuration.topology?.pvSurfaces) ? configuration.topology.pvSurfaces.slice() : [];
if (!pvSurfaces.length && configuration.manager?.properties?.AnlagenPVFlaechen) {
try {
var legacySurfaces = JSON.parse(configuration.manager.properties.AnlagenPVFlaechen);
if (Array.isArray(legacySurfaces)) pvSurfaces = legacySurfaces;
} catch (_) {}
}
var pvSurfaceQuantity = configuration.pvSystem?.enabled ? Math.max(1, pvSurfaces.length) : 0;
form.elements.pvSurfaceQuantity.value = pvSurfaceQuantity;
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", 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));
var items = Array.isArray(source.items) ? source.items.slice() : [];
while (items.length < quantity) items.push(clientLegacyItem(key, source, items.length));
form.elements[quantityNames[key]].value = quantity;
renderDeviceEditor(key, quantity, items);
});
form.elements.costReportEnabled.checked = Boolean(configuration.costReport?.enabled);
form.elements.costPowerMeters.value = configuration.costReport?.powerMeters || 0;
form.elements.costAuxMeters.value = configuration.costReport?.auxiliaryMeters || 0;
byId("packageResult").classList.add("hidden");
showWizardStep(0);
updateRecommendation();
showView("configurator");
}
function energyIntegration(values, prefix) {
return {
profile: values[prefix + "Profile"],
host: values[prefix + "Host"],
port: Number(values[prefix + "Port"] || 0),
unitId: Number(values[prefix + "UnitId"] || 1),
sourceVariableId: Number(values[prefix + "SourceVariableId"] || 0),
notes: values[prefix + "Notes"] || ""
};
}
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", heat_pump: "heatPumpQuantity" };
var devices = {};
Object.keys(deviceLabels).forEach(function (key) {
devices[key] = { quantity: Number(values[quantityNames[key]] || 0), items: collectDeviceItems(key) };
});
return {
plantId: form.dataset.plantId || "",
name: values.name,
location: values.location,
siteType: values.siteType,
peakShaving: Boolean(values.peakShaving),
manager: { properties: readSchemaValues(byId("managerConfigurationFields"), state.wizardCatalog?.managerFields || [], "data-manager-property") },
gridMeasurement: energyIntegration(values, "grid"),
pvSystem: Object.assign({
enabled: Boolean(values.pvEnabled),
name: values.pvName,
acPowerKw: Number(values.pvAcPowerKw || 0)
}, energyIntegration(values, "pv")),
topology: { pvSurfaces: collectPvSurfaces() },
costReport: { enabled: Boolean(values.costReportEnabled), powerMeters: Number(values.costPowerMeters || 0), auxiliaryMeters: Number(values.costAuxMeters || 0) },
devices: devices
};
}
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"));
var manager = payload.peakShaving ? "Manager mit Peak Shaving" : "Manager ohne Peak Shaving";
byId("recommendManager").textContent = manager;
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(coverage[key] || 0);
var missing = Math.max(0, requested - licensed);
var licenseText = missing
? licensed + " lizenziert · " + missing + " zusätzliche " + (missing === 1 ? "Lizenz" : "Lizenzen") + " erforderlich"
: requested + " vollständig lizenziert";
return "<div><span>" + escapeHtml(deviceLabels[key]) + "<small class='" + (missing ? "license-missing" : "license-covered") + "'>" + escapeHtml(licenseText) + "</small></span><strong>× " + requested + "</strong></div>";
});
byId("recommendModules").innerHTML = rows.length ? rows.join("") : "<p>Keine Verbrauchermodule gewählt.</p>";
if (byId("wizardReview")) {
var deviceSummary = rows.length ? rows.join("") : "<div><span>Verbrauchermodule</span><strong>Keine</strong></div>";
byId("wizardReview").innerHTML =
"<div><span>System</span><strong>" + escapeHtml(payload.name || "Noch nicht angegeben") + "</strong></div>" +
"<div><span>Standort</span><strong>" + escapeHtml(payload.location || "Noch nicht angegeben") + "</strong></div>" +
"<div><span>Anlagentyp</span><strong>" + escapeHtml(payload.siteType) + "</strong></div>" +
"<div><span>Manager</span><strong>" + escapeHtml(manager) + "</strong></div>" +
"<div><span>Netzmessung</span><strong>" + escapeHtml(selectedProfile("energy", payload.gridMeasurement.profile).name + (payload.gridMeasurement.host ? " · " + payload.gridMeasurement.host : "")) + "</strong></div>" +
"<div><span>PV-Anlage</span><strong>" + escapeHtml(payload.pvSystem.enabled ? selectedProfile("energy", payload.pvSystem.profile).name + (payload.pvSystem.host ? " · " + payload.pvSystem.host : "") : "Nicht gewählt") + "</strong></div>" +
"<div><span>Verbrauchskostenreport</span><strong>" + escapeHtml(payload.costReport.enabled ? "Ja · separater Lizenzcode" : "Nein") + "</strong></div>" + deviceSummary;
}
}
function showWizardStep(step) {
var steps = document.querySelectorAll("[data-wizard-step]");
state.wizardStep = Math.max(0, Math.min(steps.length - 1, step));
steps.forEach(function (node, index) { node.classList.toggle("active", index === state.wizardStep); });
document.querySelectorAll("[data-wizard-marker]").forEach(function (node, index) {
node.classList.toggle("active", index === state.wizardStep);
node.classList.toggle("done", index < state.wizardStep);
});
byId("wizardStepCount").textContent = "Schritt " + (state.wizardStep + 1) + " von " + steps.length;
byId("wizardStepTitle").textContent = wizardTitles[state.wizardStep];
byId("wizardBack").disabled = state.wizardStep === 0;
byId("wizardNext").classList.toggle("hidden", state.wizardStep === steps.length - 1);
byId("wizardSubmit").classList.toggle("hidden", state.wizardStep !== steps.length - 1);
if (state.wizardStep === steps.length - 1) updateRecommendation();
}
function currentWizardStepValid() {
var step = document.querySelector("[data-wizard-step='" + state.wizardStep + "']");
var form = byId("systemForm");
step.querySelectorAll("input, select, textarea").forEach(function (field) { field.setCustomValidity(""); });
if (state.wizardStep === 1) {
[["grid", true], ["pv", form.elements.pvEnabled.checked]].forEach(function (entry) {
if (!entry[1]) return;
var profile = selectedProfile("energy", form.elements[entry[0] + "Profile"].value);
var host = form.elements[entry[0] + "Host"];
if (profile.requiresHost && !host.value.trim()) host.setCustomValidity("Bitte IP-Adresse oder Hostnamen angeben.");
});
}
step.querySelectorAll("[data-integration-field='profile']").forEach(function (select) {
var instance = select.closest("[data-device-instance]");
var editor = select.closest("[data-device-editor]");
var scope = select.closest("[data-main-integration], [data-sensor-integration]") || instance;
var kind = select.closest("[data-sensor-integration]") ? "sensor" : integrationKind(editor?.dataset.deviceEditor || "");
var profile = selectedProfile(kind, select.value);
var requiredField = profile.requiresHost ? "host" : (profile.requiresTopic ? "topicPrefix" : "");
var control = requiredField ? scope.querySelector("[data-integration-field='" + requiredField + "']") : null;
if (control && !control.value.trim()) control.setCustomValidity(profile.requiresHost
? "Bitte IP-Adresse oder Hostnamen angeben."
: "Bitte MQTT-Topic-Präfix angeben.");
});
var invalid = Array.from(step.querySelectorAll("input, select, textarea")).find(function (field) { return !field.checkValidity(); });
if (!invalid) return true;
invalid.reportValidity();
return false;
}
async function submitAuth(form, endpoint, errorId) {
var errorNode = byId(errorId); errorNode.textContent = ""; setBusy(form, true);
try {
var result = await api(endpoint, { method: "POST", body: Object.fromEntries(new FormData(form).entries()) });
form.reset();
if (result.pendingVerification) { switchAuth("login"); authNotice(result.message, !result.delivered); return; }
await showPortal(result);
toast(endpoint.endsWith("register") ? "Konto wurde erstellt." : "Erfolgreich angemeldet.");
} catch (error) { errorNode.textContent = error.message; } finally { setBusy(form, false); }
}
byId("loginTab").addEventListener("click", function () { switchAuth("login"); });
byId("registerTab").addEventListener("click", function () { switchAuth("register"); });
byId("forgotPasswordButton").addEventListener("click", function () { authNotice(""); specialAuth("forgotForm"); });
byId("resendVerificationButton").addEventListener("click", async function () {
var email = byId("loginForm").querySelector("input[name='email']").value.trim();
if (!email) return void (byId("loginError").textContent = "Bitte zuerst die E-Mail-Adresse eingeben.");
try { authNotice((await api("/api/auth/resend-verification", { method: "POST", body: { email: email } })).message); } catch (error) { byId("loginError").textContent = error.message; }
});
document.querySelectorAll("[data-auth-login]").forEach(function (button) { button.addEventListener("click", function () { resetToken = ""; authNotice(""); switchAuth("login"); }); });
byId("loginForm").addEventListener("submit", function (event) { event.preventDefault(); submitAuth(event.currentTarget, "/api/auth/login", "loginError"); });
byId("registerForm").addEventListener("submit", function (event) { event.preventDefault(); submitAuth(event.currentTarget, "/api/auth/register", "registerError"); });
byId("forgotForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { var result = await api("/api/auth/forgot-password", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); switchAuth("login"); authNotice(result.message); } catch (error) { byId("forgotError").textContent = error.message; } finally { setBusy(form, false); } });
byId("resetForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; var values = Object.fromEntries(new FormData(form).entries()); if (values.password !== values.password_repeat) return void (byId("resetError").textContent = "Die Passwörter stimmen nicht überein."); setBusy(form, true); try { var result = await api("/api/auth/reset-password", { method: "POST", body: { token: resetToken, password: values.password } }); resetToken = ""; form.reset(); switchAuth("login"); authNotice(result.message); } catch (error) { byId("resetError").textContent = error.message; } finally { setBusy(form, false); } });
document.querySelectorAll(".nav-item").forEach(function (item) { item.addEventListener("click", function () { showView(item.dataset.view); }); });
document.addEventListener("click", async function (event) {
var goto = event.target.closest("[data-goto]");
if (goto) {
var gotoDialog = goto.closest("dialog");
if (gotoDialog) gotoDialog.close();
if (goto.dataset.goto === "configurator" && goto.closest("#newLicenseDialog")) {
byId("systemForm").reset();
delete byId("systemForm").dataset.plantId;
state.deviceDrafts = {};
state.pvSurfaceDrafts = [];
renderManagerFields({});
Object.keys(deviceLabels).forEach(function (key) { renderDeviceEditor(key, 0, []); });
renderPvSurfaceEditor(0, []);
updateEnergySourceVisibility(byId("systemForm"), "grid");
updateEnergySourceVisibility(byId("systemForm"), "pv");
updatePvTopologyVisibility(byId("systemForm"));
showWizardStep(0);
updateRecommendation();
}
showView(goto.dataset.goto);
}
var newLicense = event.target.closest("[data-open-new-license]");
if (newLicense) byId("newLicenseDialog").showModal();
var open = event.target.closest("[data-open-plant]");
if (open) {
var sourceDialog = open.closest("dialog");
if (sourceDialog) sourceDialog.close();
byId("plantDialog").showModal();
}
var openBilling = event.target.closest("[data-open-billing]");
if (openBilling) {
var billingSourceDialog = openBilling.closest("dialog");
if (billingSourceDialog) billingSourceDialog.close();
var billingForm = byId("billingForm");
billingForm.reset();
delete billingForm.dataset.plantId;
byId("billingIdentityFields").classList.remove("hidden");
byId("billingDialog").showModal();
}
var billingOrder = event.target.closest("[data-billing-order]");
if (billingOrder) {
var existingBillingForm = byId("billingForm");
existingBillingForm.reset();
existingBillingForm.dataset.plantId = billingOrder.dataset.billingOrder;
var existingBillingPlant = state.plants.find(function (plant) { return plant.id === billingOrder.dataset.billingOrder; });
existingBillingForm.querySelector("[name='name']").value = existingBillingPlant?.name || "Abrechnung";
existingBillingForm.querySelector("[name='location']").value = existingBillingPlant?.location || "Schweiz";
byId("billingIdentityFields").classList.add("hidden");
byId("billingDialog").showModal();
}
var order = event.target.closest("[data-order]"); if (order) { byId("orderPlant").value = order.dataset.order; showView("licenses"); prepareOrderEditor(); }
var configure = event.target.closest("[data-configure-current]");
if (configure) configurePlant(selectedPlant("orderPlant"));
var replacementButton = event.target.closest("[data-replace-code]");
if (replacementButton) {
openReplacementDialog(
replacementButton.dataset.replaceCode,
replacementButton.dataset.replacePlant,
replacementButton.dataset.replaceConnected === "true"
);
}
var deleteCodeButton = event.target.closest("[data-delete-code]");
if (deleteCodeButton) openCodeDeletionDialog(deleteCodeButton.dataset.deleteCode, deleteCodeButton.dataset.deleteCodePlant);
var deletePlantButton = event.target.closest("[data-delete-plant]");
if (deletePlantButton) {
openPlantDeletionDialog(
deletePlantButton.dataset.deletePlant,
deletePlantButton.dataset.deletePlantName,
deletePlantButton.dataset.deletePlantPaid === "true"
);
}
var codeButton = event.target.closest("[data-code]");
if (codeButton) {
if (codeButton.dataset.rotate === "true") {
var sourceOrder = state.orders.find(function (order) { return order.id === codeButton.dataset.code; });
openReplacementDialog(codeButton.dataset.code, sourceOrder?.plant_name || "dieses System", Boolean(sourceOrder?.activated_installation_id));
return;
}
codeButton.disabled = true;
try {
var result = await api("/api/orders/" + codeButton.dataset.code + "/activation-code", { method: "POST", body: { rotate: false } });
toast(result.message);
await loadOrders();
} catch (error) { toast(error.message); } finally { codeButton.disabled = false; }
}
});
document.querySelectorAll("[data-close-dialog]").forEach(function (button) { button.addEventListener("click", function () { button.closest("dialog").close(); }); });
document.querySelectorAll(".module-check, .qty").forEach(function (input) { input.addEventListener("change", updateOrder); });
byId("peakUpgrade").addEventListener("change", updateOrder);
byId("orderPlant").addEventListener("change", function () { prepareOrderEditor(); });
byId("forecastPlant").addEventListener("change", updateForecast);
byId("forecastHistoryDays").addEventListener("change", function (event) {
state.forecastHistoryDays = Number(event.target.value) || 1;
state.forecastRangeStart = null;
state.forecastRangeEnd = null;
state.forecastVisibleSeries = null;
updateForecast();
});
byId("forecastSeriesControls").addEventListener("change", function (event) {
var control = event.target.closest("[data-forecast-series]");
if (!control || !state.forecastResult) return;
if (!state.forecastVisibleSeries) state.forecastVisibleSeries = new Set();
if (control.checked) state.forecastVisibleSeries.add(control.dataset.forecastSeries);
else state.forecastVisibleSeries.delete(control.dataset.forecastSeries);
renderForecastChart(state.forecastResult.series || []);
});
["forecastRangeStart", "forecastRangeEnd"].forEach(function (id) {
byId(id).addEventListener("change", function () {
if (!state.forecastResult) return;
var start = Date.parse(byId("forecastRangeStart").value);
var end = Date.parse(byId("forecastRangeEnd").value);
if (Number.isFinite(start)) state.forecastRangeStart = start;
if (Number.isFinite(end)) state.forecastRangeEnd = end;
renderForecastChart(state.forecastResult.series || []);
});
});
byId("forecastRangeReset").addEventListener("click", function () {
if (!state.forecastResult) return;
state.forecastRangeStart = null;
state.forecastRangeEnd = null;
renderForecastChart(state.forecastResult.series || []);
});
byId("faultPlant").addEventListener("change", updateFaults);
byId("faultRecipientForm").addEventListener("submit", async function (event) {
event.preventDefault();
var form = event.currentTarget;
var plant = selectedPlant("faultPlant");
if (!plant) return;
byId("faultRecipientError").textContent = "";
setBusy(form, true);
try {
var result = await api("/api/plants/" + plant.id + "/fault-notification-recipients", {
method: "POST",
body: { email: byId("faultRecipientEmail").value }
});
form.reset();
toast(result.message);
await updateFaults();
} catch (error) {
byId("faultRecipientError").textContent = error.message;
} finally {
setBusy(form, false);
}
});
byId("faultRecipientList").addEventListener("click", async function (event) {
var button = event.target.closest("[data-remove-fault-recipient]");
var plant = selectedPlant("faultPlant");
if (!button || !plant) return;
button.disabled = true;
try {
var result = await api("/api/plants/" + plant.id + "/fault-notification-recipients", {
method: "DELETE",
body: { email: button.dataset.removeFaultRecipient }
});
toast(result.message);
await updateFaults();
} catch (error) {
byId("faultRecipientError").textContent = error.message;
button.disabled = false;
}
});
["import", "export"].forEach(function (direction) {
var form = byId("forecastConfigForm");
var prefix = direction === "import" ? "bezug" : "einspeisung";
form.elements["tarif_" + prefix + "_modus"].addEventListener("change", function () {
renderTariffSelector(form, direction, this.value, "", form.elements["tarif_" + prefix + "_fest"].value);
});
form.elements["tarif_" + prefix].addEventListener("change", function () {
refreshTariffSelector(form, direction);
});
});
byId("forecastAddRoof").addEventListener("click", function () {
var roofs = Array.from(byId("forecastRoofs").querySelectorAll(".forecast-roof-row")).map(function (row) {
return {
kwp: Number(row.querySelector("[data-roof-field='kwp']").value),
neigung: Number(row.querySelector("[data-roof-field='neigung']").value),
azimut: Number(row.querySelector("[data-roof-field='azimut']").value)
};
});
roofs.push({ kwp: 10, neigung: 30, azimut: 180 });
renderForecastRoofs(roofs, false);
});
byId("forecastRoofs").addEventListener("click", function (event) {
var remove = event.target.closest("[data-remove-roof]");
if (remove) remove.closest(".forecast-roof-row").remove();
});
byId("forecastConfigForm").addEventListener("submit", async function (event) {
event.preventDefault();
var form = event.currentTarget;
var plantId = form.dataset.plantId;
byId("forecastConfigError").textContent = "";
var payload;
try {
payload = forecastConfigurationPayload(form);
} catch (error) {
byId("forecastConfigError").textContent = error.message;
return;
}
setBusy(form, true);
try {
var result = await api("/api/plants/" + plantId + "/prognosis/configuration", {
method: "PUT",
body: payload
});
var plant = state.plants.find(function (entry) { return entry.id === plantId; });
if (plant) fillForecastConfiguration(plant, result);
toast(result.message);
await updateForecast();
} catch (error) {
byId("forecastConfigError").textContent = error.message;
} finally {
setBusy(form, false);
}
});
byId("accountButton").addEventListener("click", function () { showView("account"); });
byId("systemForm").addEventListener("click", function (event) {
var instance = event.target.closest("[data-device-instance]");
var editor = event.target.closest("[data-device-editor]");
if (!instance || !editor) return;
var key = editor.dataset.deviceEditor;
if (event.target.closest("[data-add-power-level]")) {
var items = collectDeviceItems(key);
var position = Number(instance.dataset.deviceInstance);
items[position].powerLevels = items[position].powerLevels || [];
items[position].powerLevels.push({
Stufe: items[position].powerLevels.length + 1,
Leistung: 0,
integration: { profile: "manual", sourceVariableId: 0 }
});
renderDeviceEditor(key, items.length, items);
}
if (event.target.closest("[data-remove-power-level]")) {
var levelIndex = Array.from(instance.querySelectorAll("[data-power-level]")).indexOf(event.target.closest("[data-power-level]"));
var levelItems = collectDeviceItems(key);
levelItems[Number(instance.dataset.deviceInstance)].powerLevels.splice(levelIndex, 1);
renderDeviceEditor(key, levelItems.length, levelItems);
}
if (event.target.closest("[data-add-schedule-row]")) {
var scheduleItems = collectDeviceItems(key);
scheduleItems[Number(instance.dataset.deviceInstance)].schedule.push({ Uhrzeit: "00:00", Solltemperatur: 50 });
renderDeviceEditor(key, scheduleItems.length, scheduleItems);
}
if (event.target.closest("[data-remove-schedule-row]")) {
var scheduleIndex = Array.from(instance.querySelectorAll("[data-schedule-row]")).indexOf(event.target.closest("[data-schedule-row]"));
var reducedItems = collectDeviceItems(key);
reducedItems[Number(instance.dataset.deviceInstance)].schedule.splice(scheduleIndex, 1);
renderDeviceEditor(key, reducedItems.length, reducedItems);
}
updateRecommendation();
});
byId("systemForm").addEventListener("input", function (event) {
var key = event.target.dataset.deviceQuantity;
if (key) renderDeviceEditor(key, Math.max(0, Math.min(50, Number(event.target.value || 0))));
if (event.target.dataset.topologyQuantity === "pvSurfaces") {
renderPvSurfaceEditor(Math.max(0, Math.min(50, Number(event.target.value || 0))));
}
updateRecommendation();
});
byId("systemForm").addEventListener("change", function (event) {
if (event.target.matches("[data-profile-select='energy']")) {
var prefix = event.target.name === "gridProfile" ? "grid" : "pv";
var port = event.currentTarget.elements[prefix + "Port"];
var selected = selectedProfile("energy", event.target.value);
if (port && (!Number(port.value) || Number(port.value) === 80 || Number(port.value) === 502 || Number(port.value) === 1502)) {
port.value = selected.defaultPort || 0;
}
updateEnergySourceVisibility(event.currentTarget, prefix);
}
if (event.target.name === "pvEnabled") {
if (event.target.checked && Number(event.currentTarget.elements.pvSurfaceQuantity.value || 0) === 0) {
event.currentTarget.elements.pvSurfaceQuantity.value = 1;
renderPvSurfaceEditor(1);
}
updatePvTopologyVisibility(event.currentTarget);
}
if (event.target.matches("[data-ev-variant]")) {
var evEditor = event.target.closest("[data-device-editor]");
var evItems = collectDeviceItems("ev_charger");
renderDeviceEditor("ev_charger", evItems.length, evItems);
}
if (event.target.matches("[data-integration-field='profile']")) {
var editor = event.target.closest("[data-device-editor]");
var instance = event.target.closest("[data-device-instance]");
var key = editor?.dataset.deviceEditor || "";
var scope = event.target.closest("[data-main-integration], [data-sensor-integration]");
var kind = event.target.closest("[data-sensor-integration]") ? "sensor" : integrationKind(key);
var selectedDevice = selectedProfile(kind, event.target.value);
var devicePort = scope?.querySelector("[data-integration-field='port']");
var channel = scope?.querySelector("[data-integration-field='channel']");
if (devicePort) devicePort.value = selectedDevice.defaultPort || 0;
if (channel) {
channel.max = Math.max(0, Number(selectedDevice.outputs || 1) - 1);
channel.value = Math.min(Number(channel.value || 0), Number(channel.max));
}
var quantity = event.currentTarget.querySelector("[data-device-quantity='" + key + "']");
renderDeviceEditor(key, Number(quantity?.value || 0));
}
updateRecommendation();
});
byId("wizardBack").addEventListener("click", function () { showWizardStep(state.wizardStep - 1); });
byId("wizardNext").addEventListener("click", function () { if (currentWizardStepValid()) showWizardStep(state.wizardStep + 1); });
byId("confirmReplaceCode").addEventListener("click", async function () {
if (!state.replacementOrderId) return;
var button = byId("confirmReplaceCode");
var originalLabel = button.textContent;
byId("replaceCodeError").textContent = "";
button.disabled = true;
button.textContent = "Ersatzcode wird versendet …";
try {
var result = await api("/api/orders/" + state.replacementOrderId + "/activation-code", { method: "POST", body: { rotate: true } });
byId("replaceCodeDialog").close();
state.replacementOrderId = null;
toast(result.message);
await loadOrders();
} catch (error) {
byId("replaceCodeError").textContent = error.message;
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
});
byId("confirmDeleteCode").addEventListener("click", async function () {
if (!state.codeDeletionOrderId) return;
var button = byId("confirmDeleteCode");
var originalLabel = button.textContent;
byId("deleteCodeError").textContent = "";
button.disabled = true;
button.textContent = "Code wird gelöscht …";
try {
var result = await api("/api/orders/" + state.codeDeletionOrderId + "/activation-code", { method: "DELETE", body: { confirm: true } });
byId("deleteCodeDialog").close();
state.codeDeletionOrderId = null;
toast(result.message);
await loadOrders();
} catch (error) {
byId("deleteCodeError").textContent = error.message;
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
});
async function deleteSelectedPlant(deletePaidLicenses, errorId, buttonId) {
if (!state.plantDeletion) return;
var button = byId(buttonId);
var originalLabel = button.textContent;
byId(errorId).textContent = "";
button.disabled = true;
button.textContent = "Anlage wird gelöscht …";
try {
var result = await api("/api/plants/" + state.plantDeletion.id, {
method: "DELETE",
body: { plantName: state.plantDeletion.name, deletePaidLicenses: deletePaidLicenses }
});
byId("deletePlantDialog").close();
byId("deletePaidDialog").close();
state.plantDeletion = null;
state.configuredPlantId = null;
await Promise.all([loadPlants(), loadOrders()]);
showView("overview");
toast(result.message);
} catch (error) {
byId(errorId).textContent = error.message;
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
}
byId("confirmDeletePlant").addEventListener("click", async function () {
if (!state.plantDeletion) return;
if (state.plantDeletion.hasPaidLicense) {
byId("deletePlantDialog").close();
byId("deletePaidDialog").showModal();
return;
}
await deleteSelectedPlant(false, "deletePlantError", "confirmDeletePlant");
});
byId("confirmDeletePaid").addEventListener("click", function () {
deleteSelectedPlant(true, "deletePaidError", "confirmDeletePaid");
});
async function changeWorkspace(value) {
try { state.session = await api("/api/workspace", { method: "POST", body: { organizationId: value || null } }); renderSession(); await Promise.all([loadPlants(), loadOrders(), loadInvitations()]); showView("overview"); toast("Organisation gewechselt."); }
catch (error) { toast(error.message); renderSession(); }
}
byId("workspaceSelect").addEventListener("change", function (event) { changeWorkspace(event.target.value); });
byId("accountWorkspaceSelect").addEventListener("change", function (event) { changeWorkspace(event.target.value); });
byId("plantForm").addEventListener("submit", async function (event) {
event.preventDefault(); var form = event.currentTarget; byId("plantError").textContent = ""; setBusy(form, true);
try { var result = await api("/api/plants", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); form.querySelector("[value='manager_standard']").checked = true; byId("plantDialog").close(); state.configuredPlantId = result.plant.id; await loadPlants(); showView("licenses"); toast("Lizenzpaket wurde angelegt."); }
catch (error) { byId("plantError").textContent = error.message; } finally { setBusy(form, false); }
});
byId("billingForm").addEventListener("submit", async function (event) {
event.preventDefault();
var form = event.currentTarget;
var values = Object.fromEntries(new FormData(form).entries());
var plantId = form.dataset.plantId || "";
byId("billingError").textContent = "";
setBusy(form, true);
try {
if (!plantId) {
var created = await api("/api/plants", {
method: "POST",
body: { name: values.name, type: "Abrechnung", location: values.location, manager: "billing_manager" }
});
plantId = created.plant.id;
form.dataset.plantId = plantId;
}
var checkout = await api("/api/checkout", {
method: "POST",
body: {
plantId: plantId,
modules: {
cost_meter_power: Number(values.powerMeters || 0),
cost_meter_aux: Number(values.auxMeters || 0)
}
}
});
window.location.assign(checkout.checkoutUrl);
} catch (error) {
byId("billingError").textContent = error.message;
setBusy(form, false);
}
});
byId("systemForm").addEventListener("submit", async function (event) {
event.preventDefault(); var form = event.currentTarget; byId("systemError").textContent = ""; setBusy(form, true);
try {
var result = await api("/api/configurations", { method: "POST", body: systemPayload(form) });
state.configuredPlantId = result.plant.id;
byId("packageResult").classList.toggle("hidden", !result.packageReady);
if (result.packageReady) byId("packageDownload").href = result.packageUrl;
else byId("packageDownload").removeAttribute("href");
await Promise.all([loadPlants(), loadOrders()]);
byId("orderPlant").value = result.plant.id;
prepareOrderEditor(result.configurationId);
showView("licenses");
if (result.purchaseProposal?.required) {
toast(result.costReportPlant
? "Konfiguration gespeichert. Manager-Lizenzen und der separate Verbrauchskosten-Lizenzsatz sind in der Übersicht vorbereitet."
: "Konfiguration gespeichert. Fehlende Lizenzen und die kostenpflichtige Ersteinrichtung durch den Generator sind zur Bestellung vorbereitet.");
} else {
toast(result.delivered
? "Systemkonfiguration gespeichert und lizenziertes Einrichtungspaket per E-Mail versendet."
: "Systemkonfiguration gespeichert. Das lizenzierte Einrichtungspaket konnte nicht per E-Mail versendet werden.");
}
} catch (error) { byId("systemError").textContent = error.message; } finally { setBusy(form, false); }
});
byId("orderRecommendation").addEventListener("click", function () {
showView("licenses");
if (state.configuredPlantId) byId("orderPlant").value = state.configuredPlantId;
var plant = selectedPlant("orderPlant");
prepareOrderEditor(plant?.configuration_id || null);
});
byId("organizationForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { state.session = await api("/api/organizations", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); renderSession(); await Promise.all([loadPlants(), loadOrders(), loadInvitations()]); toast("Organisation wurde erstellt."); } catch (error) { byId("organizationError").textContent = error.message; } finally { setBusy(form, false); } });
byId("invitationForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { var result = await api("/api/invitations", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); await loadInvitations(); toast(result.message); } catch (error) { byId("invitationError").textContent = error.message; } finally { setBusy(form, false); } });
byId("passwordForm").addEventListener("submit", async function (event) {
event.preventDefault();
var form = event.currentTarget;
var values = Object.fromEntries(new FormData(form).entries());
byId("passwordError").textContent = "";
if (values.newPassword !== values.newPasswordRepeat) return void (byId("passwordError").textContent = "Die neuen Passwörter stimmen nicht überein.");
setBusy(form, true);
try {
var result = await api("/api/account/password", { method: "POST", body: { currentPassword: values.currentPassword, newPassword: values.newPassword } });
form.reset();
toast(result.message);
} catch (error) { byId("passwordError").textContent = error.message; } finally { setBusy(form, false); }
});
byId("checkoutButton").addEventListener("click", async function () {
var plant = selectedPlant("orderPlant"); if (!plant) return;
var button = byId("checkoutButton"); var modules = {};
state.selectedOrder.forEach(function (item) {
if (item.licenseQuantity > 0) modules[item.key] = item.licenseQuantity;
});
byId("checkoutError").textContent = ""; button.disabled = true; button.textContent = button.dataset.free === "true" ? "Wird freigeschaltet …" : "Stripe Checkout wird geöffnet …";
try { var result = await api("/api/checkout", { method: "POST", body: { plantId: plant.id, configurationId: state.checkoutConfigurationId, managerVariant: selectedManagerVariant(plant), modules: modules } }); window.location.assign(result.checkoutUrl); }
catch (error) { byId("checkoutError").textContent = error.message; toast(error.message); button.disabled = false; button.textContent = button.dataset.free === "true" ? "Kostenlos freischalten" : "Mit Stripe bezahlen"; }
});
byId("logoutButton").addEventListener("click", async function () { try { await api("/api/auth/logout", { method: "POST" }); } catch (_) { /* local logout */ } state.session = null; state.plants = []; state.orders = []; state.faultRequest += 1; history.replaceState(null, "", location.pathname); showAuth(); });
setInterval(function () {
if (state.session && byId("view-faults").classList.contains("active")) updateFaults();
}, 60000);
async function handleInvitationLink(token) {
try { var preview = await api("/api/invitations/preview", { method: "POST", body: { token: token } }); sessionStorage.setItem("enelix_invitation_token", token); byId("loginForm").querySelector("input[name='email']").value = preview.email; byId("registerForm").querySelector("input[name='email']").value = preview.email; authNotice("Einladung zu " + preview.organization + " für " + preview.email + "."); }
catch (error) { authNotice(error.message, true); }
}
async function bootstrap() {
showAuth();
var catalogs = await Promise.allSettled([api("/api/catalog"), api("/api/wizard-catalog")]);
if (catalogs[0].status === "fulfilled") applyCatalog(catalogs[0].value);
if (catalogs[1].status === "fulfilled") applyWizardCatalog(catalogs[1].value);
else byId("systemError").textContent = "Der Systemkonfigurator konnte nicht geladen werden.";
updateRecommendation();
showWizardStep(0);
if (location.hash.startsWith("#verify=")) { var verificationToken = decodeURIComponent(location.hash.slice(8)); history.replaceState(null, "", location.pathname); switchAuth("login"); try { authNotice((await api("/api/auth/verify-email", { method: "POST", body: { token: verificationToken } })).message); } catch (error) { authNotice(error.message, true); } return; }
if (location.hash.startsWith("#reset=")) { resetToken = decodeURIComponent(location.hash.slice(7)); history.replaceState(null, "", location.pathname); specialAuth("resetForm"); return; }
var invitationToken = location.hash.startsWith("#invite=") ? decodeURIComponent(location.hash.slice(8)) : "";
if (invitationToken) history.replaceState(null, "", location.pathname);
try { var session = await api("/api/session"); if (invitationToken) await handleInvitationLink(invitationToken); if (session.authenticated) return showPortal(session); switchAuth("login"); }
catch (_) { switchAuth("login"); byId("loginError").textContent = "Das Portal ist momentan nicht erreichbar."; }
}
bootstrap();
}());