feat(rechner): Verbrauchsrechner-Tab mit Posten-Modell (Credits-Saetze 45/40/120/1)
Eigenstaendiges Modul rechner.js, injiziert Tab, Bereich und Styles selbst. dashboard.html und main.py bleiben unveraendert, damit der Staging-Deploy nicht mit dem lokalen KB-Arbeitsstand kollidiert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -1047,3 +1047,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
loadMeta();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
|
||||
(function () {
|
||||
var s = document.createElement("script");
|
||||
s.src = "/static/js/rechner.js?v=20260724a";
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
|
||||
355
src/static/js/rechner.js
Normale Datei
355
src/static/js/rechner.js
Normale Datei
@@ -0,0 +1,355 @@
|
||||
"use strict";
|
||||
|
||||
// Verbrauchsrechner (Posten-Modell) fuer das Verwaltungsportal.
|
||||
// Eigenstaendiges Modul, injiziert Tab, Bereich und Styles selbst,
|
||||
// damit dashboard.html unveraendert bleiben kann.
|
||||
|
||||
(function () {
|
||||
var SATZ_LIVE = 45;
|
||||
var SATZ_RECHERCHE_NEU = 120;
|
||||
var SATZ_AKTUALISIERUNG = 40;
|
||||
var SATZ_NEBEN = 1;
|
||||
var USD = 0.20;
|
||||
var MINIMUM_TAKT_MINUTEN = 30;
|
||||
|
||||
var EINHEITEN = [
|
||||
{ wert: 1, text: "Minuten" },
|
||||
{ wert: 60, text: "Stunden" },
|
||||
{ wert: 1440, text: "Tage" }
|
||||
];
|
||||
|
||||
var BLOCK_NAMEN = { live: "Live-Monitoring", recherche: "Recherche", neben: "Nebenkosten" };
|
||||
var BLOCK_REIHENFOLGE = ["live", "recherche", "neben"];
|
||||
|
||||
var bloecke = {
|
||||
live: [{ anzahl: 1, nurManuell: false, taktWert: 1, taktEinheit: 1440, tage: 30, manuell: 0 }],
|
||||
recherche: [{ anzahl: 1, vertiefungen: 1 }],
|
||||
neben: [{ auto: true, aufrufe: 50 }]
|
||||
};
|
||||
|
||||
function fmt(n) { return Math.round(n).toLocaleString("de-DE"); }
|
||||
|
||||
function neuerPosten(typ) {
|
||||
if (typ === "recherche") return { anzahl: 1, vertiefungen: 1 };
|
||||
if (typ === "neben") return { auto: true, aufrufe: 50 };
|
||||
return { anzahl: 1, nurManuell: false, taktWert: 1, taktEinheit: 1440, tage: 30, manuell: 0 };
|
||||
}
|
||||
|
||||
function autoAufrufe() {
|
||||
var summe = 0;
|
||||
bloecke.live.forEach(function (p) { summe += p.anzahl; });
|
||||
bloecke.recherche.forEach(function (p) { summe += p.anzahl; });
|
||||
return Math.round(summe * 1.5);
|
||||
}
|
||||
|
||||
function postenCredits(typ, p) {
|
||||
if (typ === "live") {
|
||||
var laeufe = 1;
|
||||
if (!p.nurManuell && p.taktWert > 0) {
|
||||
var intervallMin = Math.max(MINIMUM_TAKT_MINUTEN, p.taktWert * p.taktEinheit);
|
||||
laeufe = Math.max(1, Math.floor(p.tage * 1440 / intervallMin));
|
||||
}
|
||||
return (p.anzahl * laeufe + p.manuell) * SATZ_LIVE;
|
||||
}
|
||||
if (typ === "recherche") {
|
||||
return p.anzahl * SATZ_RECHERCHE_NEU + p.vertiefungen * SATZ_AKTUALISIERUNG;
|
||||
}
|
||||
return (p.auto ? autoAufrufe() : p.aufrufe) * SATZ_NEBEN;
|
||||
}
|
||||
|
||||
function feldHtml(typ, i, name, labelText, hinweis, wert, min, max) {
|
||||
var id = "vr-" + typ + "-" + i + "-" + name;
|
||||
return '<div class="vr-field"><label for="' + id + '">' + labelText +
|
||||
(hinweis ? " <small>" + hinweis + "</small>" : "") + "</label>" +
|
||||
'<input type="number" id="' + id + '" data-feld="' + name + '" value="' + wert + '" min="' + min + '" max="' + max + '"></div>';
|
||||
}
|
||||
|
||||
function taktHtml(i, p) {
|
||||
var idW = "vr-live-" + i + "-taktwert";
|
||||
var idE = "vr-live-" + i + "-takteinheit";
|
||||
var intervallMin = p.taktWert * p.taktEinheit;
|
||||
var chips = [
|
||||
{ min: 60, text: "stündlich" },
|
||||
{ min: 720, text: "halbtäglich" },
|
||||
{ min: 1440, text: "täglich" }
|
||||
].map(function (c) {
|
||||
return '<button type="button" data-takt="' + c.min + '"' + (intervallMin === c.min ? ' class="aktiv"' : "") + ">" + c.text + "</button>";
|
||||
}).join("");
|
||||
return '<div class="vr-field"><label for="' + idW + '">Takt <small>wie oft jede Lage automatisch läuft, Minimum 30 Minuten</small></label>' +
|
||||
'<span class="vr-takt">' +
|
||||
'<input type="number" id="' + idW + '" data-feld="taktWert" value="' + p.taktWert + '" min="0" max="10000">' +
|
||||
'<select id="' + idE + '" data-feld="taktEinheit" aria-label="Einheit des Takts">' +
|
||||
EINHEITEN.map(function (o) {
|
||||
return '<option value="' + o.wert + '"' + (o.wert === p.taktEinheit ? " selected" : "") + ">" + o.text + "</option>";
|
||||
}).join("") + "</select></span></div>" +
|
||||
'<div class="vr-schnell">' + chips + "</div>";
|
||||
}
|
||||
|
||||
function postenFelder(typ, i, p) {
|
||||
if (typ === "live") {
|
||||
var idManuell = "vr-live-" + i + "-nurmanuell";
|
||||
var html = feldHtml(typ, i, "anzahl", "Anzahl Lagen", "", p.anzahl, 1, 200) +
|
||||
'<div class="vr-field"><label for="' + idManuell + '">Nur manuell <small>keine automatischen Läufe</small></label>' +
|
||||
'<input type="checkbox" id="' + idManuell + '" data-feld="nurManuell"' + (p.nurManuell ? " checked" : "") + "></div>";
|
||||
if (!p.nurManuell) {
|
||||
html += taktHtml(i, p) +
|
||||
feldHtml(typ, i, "tage", "Laufzeit in Tagen", "wie lange aktualisiert wird", p.tage, 1, 365);
|
||||
}
|
||||
html += feldHtml(typ, i, "manuell", "Aktualisierungen von Hand", "gesamt für diesen Posten", p.manuell, 0, 1000);
|
||||
return html;
|
||||
}
|
||||
if (typ === "recherche") {
|
||||
return feldHtml(typ, i, "anzahl", "Anzahl Recherchen", "", p.anzahl, 1, 200) +
|
||||
feldHtml(typ, i, "vertiefungen", "Aktualisierungen von Hand", "gesamt für diesen Posten", p.vertiefungen, 0, 1000);
|
||||
}
|
||||
var idAuto = "vr-neben-" + i + "-auto";
|
||||
var idAnz = "vr-neben-" + i + "-aufrufe";
|
||||
return '<div class="vr-field"><label for="' + idAuto + '">Automatisch berechnen <small>Anzahl Lagen und Recherchen mal 1,5</small></label>' +
|
||||
'<input type="checkbox" id="' + idAuto + '" data-feld="auto"' + (p.auto ? " checked" : "") + "></div>" +
|
||||
'<div class="vr-field"><label for="' + idAnz + '">Anzahl Aufrufe <small>Chat oder Beschreibungs-Assistent, je 1 Credit</small></label>' +
|
||||
'<input type="number" id="' + idAnz + '" data-feld="aufrufe" value="' + (p.auto ? autoAufrufe() : p.aufrufe) + '" min="0" max="100000"' + (p.auto ? " disabled" : "") + "></div>";
|
||||
}
|
||||
|
||||
function renderBlock(typ) {
|
||||
var el = document.getElementById("vr-liste-" + typ);
|
||||
if (!el) return;
|
||||
el.innerHTML = bloecke[typ].map(function (p, i) {
|
||||
return '<div class="vr-posten" data-typ="' + typ + '" data-i="' + i + '">' +
|
||||
'<div class="vr-head">' +
|
||||
'<span class="vr-title">Posten ' + (i + 1) + "</span>" +
|
||||
'<button type="button" class="vr-remove" data-remove aria-label="' + BLOCK_NAMEN[typ] + ', Posten ' + (i + 1) + ' entfernen">×</button>' +
|
||||
"</div>" +
|
||||
postenFelder(typ, i, p) +
|
||||
'<div class="vr-sum">= <b data-sum>' + fmt(postenCredits(typ, p)) + "</b> Credits</div>" +
|
||||
"</div>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function calc() {
|
||||
var total = 0;
|
||||
var zeilen = [];
|
||||
BLOCK_REIHENFOLGE.forEach(function (typ) {
|
||||
bloecke[typ].forEach(function (p, i) {
|
||||
var c = postenCredits(typ, p);
|
||||
total += c;
|
||||
zeilen.push('<div class="vr-row"><span>' + BLOCK_NAMEN[typ] + " " + (i + 1) + "</span><span>" + fmt(c) + "</span></div>");
|
||||
});
|
||||
});
|
||||
var posten = document.getElementById("vr-posten-zeilen");
|
||||
if (posten) posten.innerHTML = zeilen.length ? zeilen.join("") : '<div class="vr-row"><span>Noch keine Posten</span><span>0</span></div>';
|
||||
var totalEl = document.getElementById("vr-total");
|
||||
if (totalEl) totalEl.innerHTML = fmt(total) + "<small>Credits insgesamt</small>";
|
||||
var usdEl = document.getElementById("vr-usd");
|
||||
if (usdEl) usdEl.textContent = (total * USD).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $";
|
||||
syncNebenAnzeige();
|
||||
}
|
||||
|
||||
function syncNebenAnzeige() {
|
||||
bloecke.neben.forEach(function (p, i) {
|
||||
if (!p.auto) return;
|
||||
var wrap = document.querySelector('#vr-liste-neben .vr-posten[data-i="' + i + '"]');
|
||||
if (!wrap) return;
|
||||
var input = wrap.querySelector('input[data-feld="aufrufe"]');
|
||||
if (input) input.value = autoAufrufe();
|
||||
var sumEl = wrap.querySelector("[data-sum]");
|
||||
if (sumEl) sumEl.textContent = fmt(postenCredits("neben", p));
|
||||
});
|
||||
}
|
||||
|
||||
function wertUebernehmen(e) {
|
||||
var wrap = e.target.closest(".vr-posten");
|
||||
var name = e.target.getAttribute("data-feld");
|
||||
if (!wrap || !name) return;
|
||||
var typ = wrap.getAttribute("data-typ");
|
||||
var i = parseInt(wrap.getAttribute("data-i"), 10);
|
||||
if (name === "auto") {
|
||||
if (!e.target.checked) bloecke.neben[i].aufrufe = autoAufrufe();
|
||||
bloecke.neben[i].auto = e.target.checked;
|
||||
renderBlock("neben");
|
||||
calc();
|
||||
return;
|
||||
}
|
||||
if (name === "nurManuell") {
|
||||
bloecke.live[i].nurManuell = e.target.checked;
|
||||
renderBlock("live");
|
||||
calc();
|
||||
return;
|
||||
}
|
||||
bloecke[typ][i][name] = parseFloat(e.target.value) || 0;
|
||||
var sumEl = wrap.querySelector("[data-sum]");
|
||||
if (sumEl) sumEl.textContent = fmt(postenCredits(typ, bloecke[typ][i]));
|
||||
calc();
|
||||
}
|
||||
|
||||
function normalisiereTakt(el) {
|
||||
var wrap = el.closest(".vr-posten");
|
||||
if (!wrap) return;
|
||||
var i = parseInt(wrap.getAttribute("data-i"), 10);
|
||||
var p = bloecke.live[i];
|
||||
if (!p || p.nurManuell || p.taktWert <= 0) return;
|
||||
var intervallMin = p.taktWert * p.taktEinheit;
|
||||
if (intervallMin >= MINIMUM_TAKT_MINUTEN) return;
|
||||
p.taktWert = Math.ceil((MINIMUM_TAKT_MINUTEN / p.taktEinheit) * 100) / 100;
|
||||
var input = wrap.querySelector('input[data-feld="taktWert"]');
|
||||
if (input) input.value = p.taktWert;
|
||||
var sumEl = wrap.querySelector("[data-sum]");
|
||||
if (sumEl) sumEl.textContent = fmt(postenCredits("live", p));
|
||||
calc();
|
||||
}
|
||||
|
||||
var STYLES =
|
||||
".vr-kopf { margin-bottom: 20px; }" +
|
||||
".vr-kopf h2 { margin: 0 0 4px; font-size: 20px; }" +
|
||||
".vr-kopf p { margin: 0; font-size: 13px; color: var(--text-secondary); }" +
|
||||
".vr-calc { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }" +
|
||||
"@media (max-width: 900px) { .vr-calc { grid-template-columns: 1fr; } }" +
|
||||
".vr-block { margin-bottom: 24px; }" +
|
||||
".vr-block-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin: 0 0 10px; }" +
|
||||
".vr-posten { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 10px; }" +
|
||||
".vr-head { display: flex; align-items: center; margin-bottom: 10px; }" +
|
||||
".vr-title { font-weight: 600; font-size: 13px; }" +
|
||||
".vr-remove { margin-left: auto; width: 26px; height: 26px; background: transparent; border: 1px solid var(--border); border-radius: 6px; color: var(--text-secondary); font-size: 15px; line-height: 1; cursor: pointer; }" +
|
||||
".vr-remove:hover { border-color: var(--danger); color: var(--danger); }" +
|
||||
".vr-field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }" +
|
||||
".vr-field label { font-size: 13px; color: var(--text-primary); }" +
|
||||
".vr-field label small { display: block; font-size: 11px; color: var(--text-muted); }" +
|
||||
".vr-field input[type=number], .vr-field select { width: 110px; flex: none; padding: 6px 8px; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; }" +
|
||||
".vr-field input[type=number]::-webkit-outer-spin-button, .vr-field input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }" +
|
||||
".vr-field input[type=number] { -moz-appearance: textfield; appearance: textfield; }" +
|
||||
".vr-field input[type=checkbox] { width: 16px; height: 16px; flex: none; accent-color: var(--accent); cursor: pointer; }" +
|
||||
".vr-field input:disabled { opacity: 0.55; }" +
|
||||
".vr-field input:focus, .vr-field select:focus { outline: none; border-color: var(--accent); }" +
|
||||
".vr-takt { display: flex; gap: 6px; flex: none; }" +
|
||||
".vr-takt input { width: 58px; }" +
|
||||
".vr-takt select { width: 96px; }" +
|
||||
".vr-schnell { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; margin: -2px 0 10px; }" +
|
||||
".vr-schnell button { padding: 2px 10px; font-family: inherit; font-size: 11px; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; }" +
|
||||
".vr-schnell button:hover, .vr-schnell button.aktiv { border-color: var(--accent); color: var(--accent); }" +
|
||||
".vr-sum { border-top: 1px solid var(--bg-tertiary); margin-top: 10px; padding-top: 8px; text-align: right; font-size: 13px; color: var(--text-secondary); }" +
|
||||
".vr-sum b { color: var(--accent); font-size: 14px; }" +
|
||||
".vr-add { width: 100%; padding: 9px; background: transparent; border: 1px dashed var(--accent); border-radius: 8px; color: var(--accent); font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }" +
|
||||
".vr-add:hover { background: rgba(240, 180, 41, 0.08); }" +
|
||||
".vr-result { background: var(--bg-secondary); border: 1px solid var(--accent); border-radius: 8px; padding: 16px; position: sticky; top: 16px; }" +
|
||||
".vr-total { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; }" +
|
||||
".vr-total small { display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-top: 4px; }" +
|
||||
".vr-row { display: flex; justify-content: space-between; font-size: 13px; padding: 5px 0; border-bottom: 1px solid var(--bg-tertiary); }" +
|
||||
".vr-row:last-child { border-bottom: none; }" +
|
||||
".vr-row span:last-child { font-weight: 600; font-variant-numeric: tabular-nums; }" +
|
||||
".vr-sep { margin: 12px 0; border: none; border-top: 1px solid var(--border); }";
|
||||
|
||||
function sektionHtml() {
|
||||
return '<div class="vr-kopf">' +
|
||||
"<h2>Verbrauchsrechner</h2>" +
|
||||
"<p>Sätze = Live-Lauf 45, Recherche anlegen 120, Aktualisierung 40, Nebenkosten 1 Credit. 1 Credit = 0,20 USD Selbstkosten.</p>" +
|
||||
"</div>" +
|
||||
'<div class="vr-calc">' +
|
||||
"<div>" +
|
||||
'<div class="vr-block"><h3 class="vr-block-title">Live-Monitoring</h3><div id="vr-liste-live"></div>' +
|
||||
'<button type="button" class="vr-add" data-add="live">+ Live-Posten hinzufügen</button></div>' +
|
||||
'<div class="vr-block"><h3 class="vr-block-title">Recherche</h3><div id="vr-liste-recherche"></div>' +
|
||||
'<button type="button" class="vr-add" data-add="recherche">+ Recherche-Posten hinzufügen</button></div>' +
|
||||
'<div class="vr-block"><h3 class="vr-block-title">Nebenkosten</h3><div id="vr-liste-neben"></div>' +
|
||||
'<button type="button" class="vr-add" data-add="neben">+ Nebenkosten-Posten hinzufügen</button></div>' +
|
||||
"</div>" +
|
||||
'<div class="vr-result">' +
|
||||
'<div class="vr-total" id="vr-total">0<small>Credits insgesamt</small></div>' +
|
||||
'<hr class="vr-sep">' +
|
||||
'<div id="vr-posten-zeilen"></div>' +
|
||||
'<hr class="vr-sep">' +
|
||||
'<div class="vr-row"><span>Selbstkosten</span><span id="vr-usd">0 $</span></div>' +
|
||||
"</div>" +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
function init() {
|
||||
var nav = document.querySelector("nav.nav-tabs");
|
||||
var content = document.querySelector(".app-content");
|
||||
if (!nav || !content || document.getElementById("sec-rechner")) return;
|
||||
|
||||
var styleEl = document.createElement("style");
|
||||
styleEl.textContent = STYLES;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
var sektion = document.createElement("div");
|
||||
sektion.className = "section";
|
||||
sektion.id = "sec-rechner";
|
||||
sektion.innerHTML = sektionHtml();
|
||||
content.appendChild(sektion);
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "nav-tab";
|
||||
btn.setAttribute("data-section", "rechner");
|
||||
btn.textContent = "Verbrauchsrechner";
|
||||
nav.appendChild(btn);
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
document.querySelectorAll("nav.nav-tabs .nav-tab").forEach(function (t) { t.classList.remove("active"); });
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".app-content > .section").forEach(function (s) { s.classList.remove("active"); });
|
||||
sektion.classList.add("active");
|
||||
var detail = document.getElementById("orgDetail");
|
||||
if (detail) detail.classList.remove("active");
|
||||
});
|
||||
|
||||
nav.addEventListener("click", function (e) {
|
||||
var ziel = e.target.closest(".nav-tab");
|
||||
if (!ziel || ziel === btn) return;
|
||||
btn.classList.remove("active");
|
||||
sektion.classList.remove("active");
|
||||
});
|
||||
|
||||
sektion.addEventListener("input", function (e) {
|
||||
if (e.target.tagName === "SELECT") return;
|
||||
wertUebernehmen(e);
|
||||
});
|
||||
|
||||
sektion.addEventListener("change", function (e) {
|
||||
var name = e.target.getAttribute("data-feld");
|
||||
if (e.target.tagName === "SELECT") wertUebernehmen(e);
|
||||
if (name === "taktWert" || name === "taktEinheit") normalisiereTakt(e.target);
|
||||
});
|
||||
|
||||
sektion.addEventListener("click", function (e) {
|
||||
var taktBtn = e.target.closest("[data-takt]");
|
||||
if (taktBtn) {
|
||||
var wrapT = taktBtn.closest(".vr-posten");
|
||||
var iT = parseInt(wrapT.getAttribute("data-i"), 10);
|
||||
var minuten = parseInt(taktBtn.getAttribute("data-takt"), 10);
|
||||
var pT = bloecke.live[iT];
|
||||
if (minuten % 1440 === 0) { pT.taktWert = minuten / 1440; pT.taktEinheit = 1440; }
|
||||
else if (minuten % 60 === 0) { pT.taktWert = minuten / 60; pT.taktEinheit = 60; }
|
||||
else { pT.taktWert = minuten; pT.taktEinheit = 1; }
|
||||
renderBlock("live");
|
||||
calc();
|
||||
return;
|
||||
}
|
||||
var addBtn = e.target.closest("[data-add]");
|
||||
if (addBtn) {
|
||||
var typNeu = addBtn.getAttribute("data-add");
|
||||
bloecke[typNeu].push(neuerPosten(typNeu));
|
||||
renderBlock(typNeu);
|
||||
calc();
|
||||
return;
|
||||
}
|
||||
var removeBtn = e.target.closest("[data-remove]");
|
||||
if (removeBtn) {
|
||||
var wrap = removeBtn.closest(".vr-posten");
|
||||
var typ = wrap.getAttribute("data-typ");
|
||||
bloecke[typ].splice(parseInt(wrap.getAttribute("data-i"), 10), 1);
|
||||
renderBlock(typ);
|
||||
calc();
|
||||
}
|
||||
});
|
||||
|
||||
renderBlock("live");
|
||||
renderBlock("recherche");
|
||||
renderBlock("neben");
|
||||
calc();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren