(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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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() {
byId("portalApp").classList.add("hidden");
byId("authScreen").classList.remove("hidden");
}
function workspaceOptions() {
var organizations = state.session.organizations || [];
if (!organizations.length) return "";
return organizations.map(function (organization) { return ""; }).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) {
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 "
" + escapeHtml(invitation.invited_email) + "" + escapeHtml(roleLabels[invitation.role] || invitation.role) + "offen
";
}).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 ? "" + 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
? "
"
: "";
var deleteCodeAction = canDelete && codeOrder && codeOrder.code_hint && codeOrder.code_hint !== "Gelöscht"
? "
"
: "";
var deletePlantAction = canDelete
? "
"
: "";
var billingPlant = plant.manager_variant === "billing_manager";
var extendAction = billingPlant
? "
"
: "
";
return "
" + escapeHtml(plant.name) + "" + escapeHtml(plant.location) + "" +
"" + (billingPlant ? "Lizenztyp" : "Manager") + "" + escapeHtml(managerLabel(plant.manager_variant)) + "" +
"Lizenzen" + licenseCount + "" +
"Verwendung" + licenseState + "" +
"Verbindung" + escapeHtml(plant.installation_id ? (billingPlant ? "Abrechnung verbunden" : "Manager verbunden") : "Nicht verbunden") + "" +
"" + extendAction + codeAction + deleteCodeAction + deletePlantAction + "
";
}).join("") + "
" : "Noch keine Lizenz vorhandenLege eine Lizenz direkt an oder starte mit dem Systemkonfigurator.
";
var options = managerPlants.map(function (plant) { return ""; }).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 = "Generator / Ersteinrichtung bezahlt";
} else if (order.status === "paid" && order.code_hint === "Gelöscht") {
action = "Code gelöscht";
} else if (order.status === "paid" && order.code_hint) {
action = "" +
(canDelete ? "" : "") +
"" + escapeHtml(order.activated_installation_id ? "Verknüpft · " + order.code_hint : order.code_hint) + "";
} else if (order.status === "paid") action = "";
var receipt = order.status === "paid" ? "PDF" : "–";
return "| " + escapeHtml(new Date(order.created_at + "Z").toLocaleDateString("de-CH")) + " | " + escapeHtml(order.plant_name) + " | " + count + " | " + escapeHtml(money.format(order.amount_total / 100)) + " | " + escapeHtml(labels[order.status] || order.status) + " | " + action + " | " + receipt + " |
";
}).join("") : "| Noch keine Bestellungen in dieser Organisation. |
";
}
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 "" + escapeHtml(item.name) + " × " + item.quantity + "" + money.format(item.total) + "
"; }).join("")
: "Keine zusätzliche Lizenz oder Ersteinrichtung erforderlich.
";
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 = "" + escapeHtml(formatForecastHoverDate(referenceTime)) + "" + rows.map(function (row) {
return "" + escapeHtml(forecastSeriesLabel(row.key)) + "" + escapeHtml(formatForecastHoverValue(row.key, row.value)) + "";
}).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 ""
+ "| " + escapeHtml(forecastSeriesLabel(entry.key)) + " | "
+ "" + escapeHtml(target) + " | "
+ "" + formatForecastMetric(entry.r2, 3, "") + " | "
+ "" + formatForecastMetric(entry.mae, 0, " W") + " | "
+ "" + formatForecastMetric(entry.rmse, 0, " W") + " | "
+ "" + Number(entry.samples).toLocaleString("de-CH") + " | "
+ "
";
}).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 "";
}).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 "" + escapeHtml(forecastSeriesLabel(entry.key) + activeSchedule) + "";
}).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("");
svg.push("" + label.toFixed(1) + "");
}
var now = Date.now();
if (now >= minTime && now <= maxTime) {
var nowX = x(now);
svg.push("");
svg.push("Jetzt");
}
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("");
});
svg.push("");
svg.push("" + formatForecastDate(minTime) + "");
svg.push("" + formatForecastDate(maxTime) + "");
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 "";
}).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 "" + escapeHtml(title) + "
"
+ (items.length ? items.map(function (item) {
return "" + escapeHtml(item.name || item.id) + ""
+ escapeHtml(describe(item)) + "
";
}).join("") : "Keine Komponenten
")
+ "";
}
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 =
""
+ "
PV DC" + topologyNumber(summary.pvDcKwp, "kWp") + "
"
+ "
PV AC" + topologyNumber(summary.pvAcKw, "kW") + "
"
+ "
Batterie nutzbar" + topologyNumber(summary.usableBatteryKwh, "kWh") + "
"
+ "
Laden" + topologyNumber(summary.maxBatteryChargeKw, "kW") + "
"
+ "
Entladen" + topologyNumber(summary.maxBatteryDischargeKw, "kW") + "
"
+ "
"
+ 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);
})
+ "
";
block.classList.remove("hidden");
}
function renderForecastRoofs(roofs, readOnly) {
var disabled = readOnly ? " disabled" : "";
byId("forecastRoofs").innerHTML = (roofs || []).map(function (roof, index) {
return ""
+ ""
+ ""
+ ""
+ ""
+ "
";
}).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);
}
async function updateForecast() {
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;
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
? ""
: "";
return "" + escapeHtml(recipient.email) +
"Seit " + escapeHtml(faultDate(recipient.createdAt)) + "" + remove + "
";
}).join("")
: "Noch keine Empfänger eingetragen.
";
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 = "" + escapeHtml(fault.sourceName) + "" + escapeHtml(fault.sourceType + " · " + fault.sourceId) + "";
var message = "" + escapeHtml(fault.message) + "" + escapeHtml(fault.code) + "";
var time = history ? fault.resolvedAt : fault.firstSeenAt;
return "| " +
escapeHtml(faultSeverityLabel(fault.severity)) + " | " + source +
" | " + message + " | " + escapeHtml(faultDate(time)) + " |
";
}).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 "";
}
if (field.type === "select" || field.type === "choice") {
var options = (field.options || []).map(function (option) {
return "";
}).join("");
return "";
}
if (field.type === "json") {
return "";
}
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 "";
}
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 "" + escapeHtml(section) + "
" + controls + "
";
}).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 "";
}).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"
? ""
: "";
return "";
}
var manual = selected.id === "manual"
? "" +
(key === "consumer_single" ? "" : "")
: "";
return "";
}
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"
? ""
: "";
return "Temperaturfühler
" +
"" +
source + "
Beim Shelly Plus Add-on wird die Temperaturvariable automatisch zugeordnet.
";
}
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 "Stufe " + (index + 1) +
"
" +
renderSchemaGroups(fields, values, "data-power-level-field") + "
" +
renderIntegrationFields(key, level.integration || { profile: "manual" }, true) + "
";
}).join("");
return "Leistungsstufen
Leistung und Aktor je Stufe.
" +
"
" + rows + "
";
}
function renderScheduleRows(rows) {
var fields = state.wizardCatalog?.deviceListFields?.schedule || [];
var content = (rows || []).map(function (row, index) {
return "Zeitpunkt " + (index + 1) +
"
" +
renderSchemaGroups(fields, Object.assign(schemaDefaults(fields), row || {}), "data-schedule-field") + "
";
}).join("");
return "Solltemperatur-Zeitplan
Uhrzeiten und Solltemperaturen als Liste.
" +
"
" + content + "
";
}
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 = "";
if (key === "ev_charger") {
identity += "";
}
identity += "
";
var topology = key === "battery"
? "" + renderSchemaGroups(
state.wizardCatalog?.topologyFields?.battery || [],
Object.assign(schemaDefaults(state.wizardCatalog?.topologyFields?.battery), item.topology || {}),
"data-battery-topology"
) + "
"
: "";
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 "" +
escapeHtml(name) + "
" + identity + integration + topology + structured +
"
" + renderSchemaGroups(fields, properties, "data-device-property") + "
";
}).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 "" +
escapeHtml(values.Name || "PV-Fläche " + (index + 1)) + "
" +
renderSchemaGroups(fields, values, "data-pv-surface") + "
";
}).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 "" + escapeHtml(deviceLabels[key]) + "" + escapeHtml(licenseText) + "× " + requested + "
";
});
byId("recommendModules").innerHTML = rows.length ? rows.join("") : "Keine Verbrauchermodule gewählt.
";
if (byId("wizardReview")) {
var deviceSummary = rows.length ? rows.join("") : "VerbrauchermoduleKeine
";
byId("wizardReview").innerHTML =
"System" + escapeHtml(payload.name || "Noch nicht angegeben") + "
" +
"Standort" + escapeHtml(payload.location || "Noch nicht angegeben") + "
" +
"Anlagentyp" + escapeHtml(payload.siteType) + "
" +
"Manager" + escapeHtml(manager) + "
" +
"Netzmessung" + escapeHtml(selectedProfile("energy", payload.gridMeasurement.profile).name + (payload.gridMeasurement.host ? " · " + payload.gridMeasurement.host : "")) + "
" +
"PV-Anlage" + escapeHtml(payload.pvSystem.enabled ? selectedProfile("energy", payload.pvSystem.profile).name + (payload.pvSystem.host ? " · " + payload.pvSystem.host : "") : "Nicht gewählt") + "
" +
"Verbrauchskostenreport" + escapeHtml(payload.costReport.enabled ? "Ja · separater Lizenzcode" : "Nein") + "
" + 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();
}());