feat(rechner): Saetze per Regler einstellbar mit Empfehlung aus 30-Tage-API-Kosten
Neuer Router pricing.py (GET /api/pricing/cost-basis), liest die geteilte Live-DB read-only und liefert Median-Kosten je Lagentyp plus Credit-Empfehlung. main.py um den Router erweitert. Frontend rechner.js mit Reglern statt fester Saetze und Empfehlungsanzeige je Aktion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -1051,6 +1051,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
|
||||
(function () {
|
||||
var s = document.createElement("script");
|
||||
s.src = "/static/js/rechner.js?v=20260724b";
|
||||
s.src = "/static/js/rechner.js?v=20260724c";
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
// 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 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;
|
||||
|
||||
// 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" },
|
||||
@@ -28,6 +30,7 @@
|
||||
};
|
||||
|
||||
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 };
|
||||
@@ -49,14 +52,16 @@
|
||||
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;
|
||||
return (p.anzahl * laeufe + p.manuell) * saetze.live;
|
||||
}
|
||||
if (typ === "recherche") {
|
||||
return p.anzahl * SATZ_RECHERCHE_NEU + p.vertiefungen * SATZ_AKTUALISIERUNG;
|
||||
return p.anzahl * rechercheNeu() + p.vertiefungen * saetze.research;
|
||||
}
|
||||
return (p.auto ? autoAufrufe() : p.aufrufe) * SATZ_NEBEN;
|
||||
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 +
|
||||
@@ -125,6 +130,68 @@
|
||||
}).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 === "research" ? '<div class="vr-note" id="vr-note-research"></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 note = document.getElementById("vr-note-research");
|
||||
if (note) note.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 = [];
|
||||
@@ -140,7 +207,7 @@
|
||||
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 }) + " $";
|
||||
if (usdEl) usdEl.textContent = fmtUsd(total * USD);
|
||||
syncNebenAnzeige();
|
||||
}
|
||||
|
||||
@@ -156,6 +223,23 @@
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -197,16 +281,24 @@
|
||||
calc();
|
||||
}
|
||||
|
||||
// ---------- Styles ----------
|
||||
|
||||
var STYLES =
|
||||
".vr-kopf { margin-bottom: 16px; }" +
|
||||
".vr-kopf h2 { margin: 0 0 4px; font-size: 20px; }" +
|
||||
".vr-karten { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; margin-bottom: 10px; }" +
|
||||
".vr-karte { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }" +
|
||||
".vr-karte-kicker { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 6px; }" +
|
||||
".vr-karte-titel { font-size: 14px; font-weight: 600; margin-bottom: 6px; }" +
|
||||
".vr-karte-wert { font-size: 30px; font-weight: 700; color: var(--accent); line-height: 1.1; }" +
|
||||
".vr-karte-wert small { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-left: 4px; }" +
|
||||
".vr-karte-hinweis { font-size: 12px; color: var(--text-muted); margin-top: 6px; }" +
|
||||
".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; } }" +
|
||||
@@ -244,24 +336,17 @@
|
||||
".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 karteHtml(kicker, titel, credits, hinweis) {
|
||||
return '<div class="vr-karte">' +
|
||||
'<div class="vr-karte-kicker">' + kicker + "</div>" +
|
||||
'<div class="vr-karte-titel">' + titel + "</div>" +
|
||||
'<div class="vr-karte-wert">' + credits + " <small>Credits</small></div>" +
|
||||
'<div class="vr-karte-hinweis">' + hinweis + "</div>" +
|
||||
"</div>";
|
||||
}
|
||||
// ---------- 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-karten">' +
|
||||
karteHtml("Live-Monitoring", "Jeder Lauf", 45, "Anlegen und jede Aktualisierung einer Live-Lage") +
|
||||
karteHtml("Recherche", "Anlegen", 120, "Der erste Lauf fährt automatisch drei Durchläufe zu je 40") +
|
||||
karteHtml("Recherche", "Jede Aktualisierung", 40, "Wird von Hand gestartet und zählt als einzelner Durchlauf") +
|
||||
karteHtml("Nebenkosten", "Je Aufruf", 1, "Chat oder Beschreibungs-Assistent") +
|
||||
'<div class="vr-regler-grid">' +
|
||||
reglerHtml("live", "Live-Monitoring", "Jeder Lauf", 1, 100) +
|
||||
reglerHtml("research", "Recherche", "Je 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">' +
|
||||
@@ -320,7 +405,14 @@
|
||||
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);
|
||||
});
|
||||
@@ -332,6 +424,21 @@
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -363,10 +470,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
updateWerte();
|
||||
renderBlock("live");
|
||||
renderBlock("recherche");
|
||||
renderBlock("neben");
|
||||
calc();
|
||||
ladeEmpfehlung();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren