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