(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 vorhanden

Lege 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("