Promote develop → main (2026-07-24 20:37 UTC)

This commit was merged in pull request #15.
Dieser Commit ist enthalten in:
2026-07-24 22:37:51 +02:00
Commit 8dde6b24f2
5 geänderte Dateien mit 574 neuen und 1 gelöschten Zeilen

Datei anzeigen

@@ -1,4 +1,15 @@
[
{
"version": "2026-07-24T20:37Z",
"date": "2026-07-24",
"title": "Neuer Verbrauchsrechner im Verwaltungsbereich",
"items": [
"Neuer Rechner-Tab zeigt Kosten übersichtlich nach Verbrauchsposten (Credits-Basierte Abrechnung)",
"Übersichtskarten mit aktuellen Credit-Sätzen direkt über dem Rechner",
"Credit-Sätze per Regler individuell anpassbar – mit Empfehlung aus den letzten 30 Tagen",
"Änderungen an den Reglern werden sofort in den Beschriftungen sichtbar"
]
},
{
"version": "2026-05-22T12:41Z",
"date": "2026-05-22",

Datei anzeigen

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from config import STATIC_DIR, PORT
from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper
from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit, translation, x_scraper, pricing
logging.basicConfig(
level=logging.INFO,
@@ -44,6 +44,7 @@ app.include_router(token_usage.router)
app.include_router(audit.router)
app.include_router(translation.router)
app.include_router(x_scraper.router)
app.include_router(pricing.router)
# --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

66
src/routers/pricing.py Normale Datei
Datei anzeigen

@@ -0,0 +1,66 @@
"""Preis-Empfehlung aus den echten API-Kosten der letzten Tage.
Reine Leseabfrage. Die Empfehlung ist eine betreiberweite Kennzahl (Median der
tatsaechlichen Refresh-Kosten je Lagentyp), deshalb wird die geteilte Live-DB
read-only gelesen, unabhaengig vom DB_PATH des jeweiligen Portals.
"""
import logging
import math
import os
import statistics
from collections import defaultdict
from datetime import datetime, timedelta
import aiosqlite
from fastapi import APIRouter, Depends
from auth import get_current_admin
logger = logging.getLogger("verwaltung.pricing")
router = APIRouter(prefix="/api/pricing", tags=["Pricing"])
COST_BASIS_DB = os.environ.get("COST_BASIS_DB_PATH", "/home/claude-dev/osint-data/osint.db")
USD_PER_CREDIT = 0.20
@router.get("/cost-basis")
async def cost_basis(days: int = 30, admin=Depends(get_current_admin)):
"""Median der echten Refresh-Kosten je Lagentyp und daraus abgeleitete
Credit-Empfehlung (Median USD geteilt durch 0,20 USD, aufgerundet)."""
days = max(1, min(365, days))
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
result = {"days": days, "usd_per_credit": USD_PER_CREDIT, "typen": {}}
try:
db = await aiosqlite.connect(f"file:{COST_BASIS_DB}?mode=ro", uri=True)
try:
cursor = await db.execute(
"SELECT i.type, r.total_cost_usd "
"FROM refresh_log r JOIN incidents i ON i.id = r.incident_id "
"WHERE r.status = 'completed' AND r.started_at >= ?",
(cutoff,),
)
rows = await cursor.fetchall()
finally:
await db.close()
except Exception as e:
logger.warning("cost-basis DB-Fehler: %s", e)
result["fehler"] = "Kostendaten nicht verfuegbar"
return result
gruppen = defaultdict(list)
for typ, kosten in rows:
gruppen[typ or "adhoc"].append(kosten or 0.0)
for db_key, label in (("adhoc", "live"), ("research", "research")):
werte = gruppen.get(db_key, [])
if werte:
median = statistics.median(werte)
result["typen"][label] = {
"median_usd": round(median, 2),
"n": len(werte),
"empfohlen": int(math.ceil(median / USD_PER_CREDIT)) if median > 0 else 0,
}
else:
result["typen"][label] = {"median_usd": None, "n": 0, "empfohlen": None}
return result

Datei anzeigen

@@ -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=20260724d";
document.head.appendChild(s);
})();

488
src/static/js/rechner.js Normale Datei
Datei anzeigen

@@ -0,0 +1,488 @@
"use strict";
// Verbrauchsrechner (Posten-Modell) fuer das Verwaltungsportal.
// Eigenstaendiges Modul, injiziert Tab, Bereich und Styles selbst,
// damit dashboard.html unveraendert bleiben kann.
// Saetze sind ueber Regler frei einstellbar, mit Empfehlung aus den echten
// API-Kosten der letzten 30 Tage (GET /api/pricing/cost-basis).
(function () {
var USD = 0.20;
var MINIMUM_TAKT_MINUTEN = 30;
// Frei einstellbare Saetze (Credits je Aktion).
var saetze = { live: 45, research: 40, neben: 1 };
function rechercheNeu() { return saetze.research * 3; }
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 fmtUsd(n) { return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"; }
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) * saetze.live;
}
if (typ === "recherche") {
return p.anzahl * rechercheNeu() + p.vertiefungen * saetze.research;
}
return (p.auto ? autoAufrufe() : p.aufrufe) * saetze.neben;
}
// ---------- Posten-Felder ----------
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">&times;</button>' +
"</div>" +
postenFelder(typ, i, p) +
'<div class="vr-sum">= <b data-sum>' + fmt(postenCredits(typ, p)) + "</b> Credits</div>" +
"</div>";
}).join("");
}
// ---------- Regler (frei einstellbare Saetze) ----------
function reglerHtml(satz, kicker, label, min, max) {
return '<div class="vr-regler">' +
'<div class="vr-regler-kicker">' + kicker + "</div>" +
'<div class="vr-regler-label">' + label + "</div>" +
'<div class="vr-regler-wert"><b id="vr-wert-' + satz + '">' + saetze[satz] + "</b> <small>Credits</small></div>" +
'<input type="range" id="vr-slider-' + satz + '" data-satz="' + satz + '" min="' + min + '" max="' + max + '" value="' + saetze[satz] + '">' +
(satz === "live" || satz === "research" ? '<div class="vr-note" id="vr-note-' + satz + '"></div>' : "") +
'<div class="vr-empf" id="vr-empf-' + satz + '">' +
(satz === "neben" ? "Nebenverbrauch real 0,02 bis 0,10 $ je Aufruf. 1 Credit ist die kleinste sinnvolle Einheit." : "Empfehlung wird geladen…") +
"</div>" +
"</div>";
}
function updateWerte() {
var wL = document.getElementById("vr-wert-live"); if (wL) wL.textContent = saetze.live;
var wR = document.getElementById("vr-wert-research"); if (wR) wR.textContent = saetze.research;
var wN = document.getElementById("vr-wert-neben"); if (wN) wN.textContent = saetze.neben;
var noteL = document.getElementById("vr-note-live");
if (noteL) noteL.textContent = "Gilt für Anlegen und jede Aktualisierung, gleicher Satz.";
var noteR = document.getElementById("vr-note-research");
if (noteR) noteR.textContent = "Anlegen einer Recherche = 3 × " + saetze.research + " = " + rechercheNeu() + " Credits.";
}
var empfDaten = null;
function renderEmpfehlung() {
if (!empfDaten) return;
["live", "research"].forEach(function (satz) {
var el = document.getElementById("vr-empf-" + satz);
if (!el) return;
var t = (empfDaten.typen || {})[satz];
if (!t || t.empfohlen == null || !t.median_usd) {
el.textContent = "Keine Kostendaten der letzten " + empfDaten.days + " Tage.";
return;
}
el.innerHTML = "Empfohlen <b>" + t.empfohlen + "</b> · Median " + fmtUsd(t.median_usd) +
" aus " + fmt(t.n) + " Läufen (" + empfDaten.days + " Tage) " +
'<button type="button" class="vr-uebernehmen" data-uebernehmen="' + satz + '" data-wert="' + t.empfohlen + '">übernehmen</button>';
});
}
function ladeEmpfehlung() {
var token = null;
try { token = localStorage.getItem("token"); } catch (e) {}
fetch("/api/pricing/cost-basis?days=30", {
headers: token ? { "Authorization": "Bearer " + token } : {}
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
}).then(function (data) {
empfDaten = data;
renderEmpfehlung();
}).catch(function () {
["live", "research"].forEach(function (satz) {
var el = document.getElementById("vr-empf-" + satz);
if (el) el.textContent = "Empfehlung derzeit nicht verfügbar.";
});
});
}
// ---------- Gesamtrechnung ----------
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 = fmtUsd(total * USD);
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 alleSummenNeu() {
BLOCK_REIHENFOLGE.forEach(function (typ) {
bloecke[typ].forEach(function (p, i) {
var wrap = document.querySelector("#vr-liste-" + typ + ' .vr-posten[data-i="' + i + '"]');
if (!wrap) return;
var sumEl = wrap.querySelector("[data-sum]");
if (sumEl) sumEl.textContent = fmt(postenCredits(typ, p));
});
});
}
function satzGesetzt() {
updateWerte();
alleSummenNeu();
calc();
}
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();
}
// ---------- Styles ----------
var STYLES =
".vr-kopf { margin-bottom: 16px; }" +
".vr-kopf h2 { margin: 0 0 4px; font-size: 20px; }" +
".vr-kopf p { margin: 0; font-size: 13px; color: var(--text-secondary); }" +
".vr-regler-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin-bottom: 10px; }" +
".vr-regler { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }" +
".vr-regler-kicker { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 6px; }" +
".vr-regler-label { font-size: 14px; font-weight: 600; margin-bottom: 6px; }" +
".vr-regler-wert { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; margin-bottom: 8px; }" +
".vr-regler-wert small { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-left: 4px; }" +
".vr-regler input[type=range] { width: 100%; accent-color: var(--accent); cursor: pointer; }" +
".vr-note { font-size: 12px; color: var(--text-secondary); margin-top: 6px; }" +
".vr-empf { font-size: 12px; color: var(--text-muted); margin-top: 8px; line-height: 1.5; }" +
".vr-empf b { color: var(--accent); }" +
".vr-uebernehmen { margin-left: 4px; padding: 2px 9px; font-family: inherit; font-size: 11px; background: var(--bg-tertiary); color: var(--accent); border: 1px solid var(--accent); border-radius: 999px; cursor: pointer; }" +
".vr-uebernehmen:hover { background: var(--accent); color: var(--bg-primary); }" +
".vr-basis { font-size: 13px; color: var(--text-secondary); margin: 0 0 20px; }" +
".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); }";
// ---------- Aufbau ----------
function sektionHtml() {
return '<div class="vr-kopf">' +
"<h2>Verbrauchsrechner</h2>" +
"<p>Sätze frei einstellbar. Die Empfehlung stammt aus den echten API-Kosten der letzten 30 Tage (Median je Aktion geteilt durch 0,20 USD).</p>" +
"</div>" +
'<div class="vr-regler-grid">' +
reglerHtml("live", "Live-Monitoring", "Anlegen und Aktualisierung", 1, 100) +
reglerHtml("research", "Recherche", "Je Aktualisierung (Durchlauf)", 1, 100) +
reglerHtml("neben", "Nebenkosten", "Je Aufruf", 1, 10) +
"</div>" +
'<p class="vr-basis">1 Credit = 0,20 USD Selbstkosten.</p>' +
'<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");
});
// Regler-Slider live in die Saetze uebernehmen.
sektion.addEventListener("input", function (e) {
if (e.target.type === "range") {
var satz = e.target.getAttribute("data-satz");
saetze[satz] = parseInt(e.target.value, 10) || 1;
satzGesetzt();
return;
}
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 uebBtn = e.target.closest("[data-uebernehmen]");
if (uebBtn) {
var s = uebBtn.getAttribute("data-uebernehmen");
var w = parseInt(uebBtn.getAttribute("data-wert"), 10);
if (w > 0) {
saetze[s] = w;
var slider = document.getElementById("vr-slider-" + s);
if (slider) {
if (w > parseInt(slider.max, 10)) slider.max = w;
slider.value = w;
}
satzGesetzt();
}
return;
}
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();
}
});
updateWerte();
renderBlock("live");
renderBlock("recherche");
renderBlock("neben");
calc();
ladeEmpfehlung();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();