Sammelstand der offenen Arbeit im Dashboard. Einstellungen: eine Eingabemaske fuer die Energiepreise unter restricted/settings.php, dazu costs.php als Modell und ajax/settings.php als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km, kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern. solarLog_costs.sql beschreibt die Umstellung der Tabellen. Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php. Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php), dessen Ablage unter tiles/ nicht ins Repository gehoert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
10 KiB
JavaScript
255 lines
10 KiB
JavaScript
/*
|
||
* Einstellungsseite: Preistabellen bearbeiten.
|
||
*
|
||
* Eine Preistabelle ist eine Zeitreihe - ab einem Stichtag gilt ein Preis, bis
|
||
* der naechste Stichtag kommt. Deshalb gibt es hier nur ein Datumsfeld je
|
||
* Zeile. Wo eine Zeile endet, steht als grauer Text daneben und ergibt sich
|
||
* aus der naechsten; die juengste Zeile endet gar nicht, sie gilt weiter, bis
|
||
* ein neuer Preis eingetragen wird.
|
||
*
|
||
* Frueher stand das Ende als eigene Spalte in der Datenbank, und weil es
|
||
* eigenstaendig gepflegt wurde, konnte es dem naechsten Stichtag widersprechen:
|
||
* zwei Zeilen liefen ueber dasselbe Quartal, die Jahresstatistik zaehlte diese
|
||
* Tage doppelt. Ohne die Spalte laesst sich das nicht mehr hinschreiben.
|
||
*
|
||
* Gezeichnet wird aus dem Modell, nicht aus dem DOM: karte._modell ist die
|
||
* Wahrheit, jede Eingabe schreibt hinein und loest ein Neuzeichnen aus.
|
||
*/
|
||
|
||
// --- Datumskram ------------------------------------------------------------
|
||
|
||
/** Ein Datum "JJJJ-MM-TT" um $tage verschieben. */
|
||
function tagVersatz(iso, tage) {
|
||
const d = new Date(iso + "T00:00:00Z");
|
||
d.setUTCDate(d.getUTCDate() + tage);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
/** "2025-01-01" -> "01.01.2025" */
|
||
function datumLesbar(iso) {
|
||
if (!iso) return "–";
|
||
const t = iso.split("-");
|
||
return t[2] + "." + t[1] + "." + t[0];
|
||
}
|
||
|
||
function heuteISO() {
|
||
const d = new Date();
|
||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
// --- Modell ---------------------------------------------------------------
|
||
|
||
/** Zeilen aufsteigend nach Stichtag. */
|
||
function sortiert(zeilen) {
|
||
return zeilen.slice().sort((a, b) => (a.ab < b.ab ? -1 : a.ab > b.ab ? 1 : 0));
|
||
}
|
||
|
||
/**
|
||
* Die einzige Luecke, die es noch geben kann: vor dem aeltesten Stichtag gibt
|
||
* es keinen Preis. Messwerte, die davor liegen, fallen aus der Jahresstatistik
|
||
* heraus, weil der JOIN fuer sie keine Zeile findet. Passieren kann das, wenn
|
||
* jemand den aeltesten Zeitraum loescht oder nach hinten schiebt - deshalb
|
||
* wird die Warnung bei jedem Neuzeichnen geprueft, nicht nur beim Laden.
|
||
*/
|
||
function zeitraumWarnung(zeilen) {
|
||
const liste = sortiert(zeilen);
|
||
if (!ersteMessung || !liste.length || liste[0].ab <= ersteMessung) return [];
|
||
return ["Die Messwerte beginnen am " + datumLesbar(ersteMessung) + ", der älteste Preis"
|
||
+ " erst am " + datumLesbar(liste[0].ab) + ". Alles davor fehlt in der Statistik."];
|
||
}
|
||
|
||
// --- Zeichnen -------------------------------------------------------------
|
||
|
||
function preisZeile(karte, zeile, i, anzahl) {
|
||
const daten = preisDaten[karte.dataset.reihe];
|
||
const felder = daten.felder;
|
||
// Die Spaltenbreiten kommen aus preisSpalten() im PHP - Kopfzeile und
|
||
// Felder muessen dieselben benutzen, sonst stehen die Ueberschriften schief.
|
||
const breit = daten.spalten;
|
||
const letzte = (i === anzahl - 1);
|
||
const heute = heuteISO();
|
||
// Die juengste Zeile hat kein Ende; alle anderen enden am Tag vor der
|
||
// naechsten.
|
||
const bis = letzte ? null : tagVersatz(karte._modell[i + 1].ab, -1);
|
||
const gilt = zeile.ab <= heute && (bis === null || heute <= bis);
|
||
|
||
let html = "<div class='row g-2 align-items-center preis-zeile" + (gilt ? " preis-heute" : "")
|
||
+ "' data-i='" + i + "'>";
|
||
|
||
html += "<div class='col-6 col-xl-" + breit.ab + "'>"
|
||
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig ab</label>"
|
||
+ "<input type='date' class='form-control form-control-sm' data-feld='ab' value='" + zeile.ab + "'>"
|
||
+ "</div>";
|
||
|
||
// Die Marke "gilt heute" steht beim Zeitraum, nicht beim Papierkorb: sie
|
||
// beschreibt ihn, und die Aktionsspalte bleibt schmal genug, dass die
|
||
// Felder den Platz bekommen.
|
||
html += "<div class='col-6 col-xl-" + breit.bis + "'>"
|
||
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig bis</label>"
|
||
+ "<div class='form-control form-control-sm border-0 bg-transparent px-0"
|
||
+ " d-flex align-items-center gap-2'>"
|
||
+ "<span class='text-secondary'>" + (bis === null ? "offen" : datumLesbar(bis)) + "</span>"
|
||
+ (gilt ? "<span class='badge text-bg-primary'>gilt heute</span>" : "")
|
||
+ "</div></div>";
|
||
|
||
Object.keys(felder).forEach(function (spalte, nr) {
|
||
const feld = felder[spalte];
|
||
// Schrittweite aus den Nachkommastellen: der Energieinhalt je Liter will
|
||
// vier Stellen, ein Preis in Cent nur zwei.
|
||
const schritt = Math.pow(10, -feld.stellen).toFixed(feld.stellen);
|
||
html += "<div class='col-6 col-xl-" + breit.felder[nr] + "'>"
|
||
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>" + feld.titel + "</label>"
|
||
+ "<div class='input-group input-group-sm'>"
|
||
+ "<input type='number' step='" + schritt + "' min='0' max='" + feld.max + "'"
|
||
+ " class='form-control text-end' data-feld='" + spalte + "'"
|
||
+ " value='" + zeile[spalte] + "'>"
|
||
+ "<span class='input-group-text'>" + feld.einheit + "</span>"
|
||
+ "</div></div>";
|
||
});
|
||
|
||
html += "<div class='col-12 col-xl-" + breit.aktion + " d-flex align-items-center justify-content-end'>";
|
||
html += "<button type='button' class='btn btn-sm btn-outline-danger preis-weg' title='Zeitraum löschen'"
|
||
+ (anzahl > 1 ? "" : " disabled") + "><i class='bi bi-trash'></i></button>"
|
||
+ "</div></div>";
|
||
return html;
|
||
}
|
||
|
||
function zeichnePreise(karte) {
|
||
karte._modell = sortiert(karte._modell);
|
||
const anzahl = karte._modell.length;
|
||
const warnung = zeitraumWarnung(karte._modell);
|
||
let html = "";
|
||
|
||
if (warnung.length) {
|
||
html += "<div class='alert alert-warning py-2 small'>"
|
||
+ "<i class='bi bi-exclamation-triangle me-1'></i>"
|
||
+ warnung.join(" ") + "</div>";
|
||
}
|
||
|
||
for (let i = 0; i < anzahl; i++) {
|
||
html += preisZeile(karte, karte._modell[i], i, anzahl);
|
||
}
|
||
|
||
karte.querySelector(".preis-zeilen").innerHTML = html;
|
||
}
|
||
|
||
// --- Bedienung ------------------------------------------------------------
|
||
|
||
function preisHinweis(karte, text, klasse) {
|
||
const feld = karte.querySelector(".preis-hinweis");
|
||
feld.className = "preis-hinweis small " + (klasse || "text-secondary");
|
||
feld.textContent = text;
|
||
}
|
||
|
||
function preisNeueZeile(karte) {
|
||
const modell = karte._modell;
|
||
const vorlage = modell[modell.length - 1];
|
||
// Der neue Stichtag ist heute; ist der schon vergeben, der naechste freie
|
||
// Tag. So entsteht nie eine Zeile, die das Speichern gleich ablehnt.
|
||
let ab = heuteISO();
|
||
while (modell.some(z => z.ab === ab)) {
|
||
ab = tagVersatz(ab, 1);
|
||
}
|
||
const zeile = { ab: ab };
|
||
for (const spalte in preisDaten[karte.dataset.reihe].felder) {
|
||
zeile[spalte] = vorlage[spalte]; // Preise uebernehmen, geaendert wird meist nur einer
|
||
}
|
||
modell.push(zeile);
|
||
zeichnePreise(karte);
|
||
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
|
||
|
||
const zeilen = karte.querySelectorAll(".preis-zeile");
|
||
const feld = zeilen[zeilen.length - 1].querySelector("[data-feld='ab']");
|
||
feld.focus();
|
||
}
|
||
|
||
async function preiseSenden(karte) {
|
||
const knopf = karte.querySelector(".preis-speichern");
|
||
knopf.disabled = true;
|
||
preisHinweis(karte, "Wird gespeichert …");
|
||
try {
|
||
const antwort = await fetch("ajax/settings.php?action=save", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ reihe: karte.dataset.reihe, zeilen: karte._modell })
|
||
});
|
||
const daten = await antwort.json();
|
||
if (!antwort.ok) {
|
||
throw new Error(daten.error || "Fehler " + antwort.status);
|
||
}
|
||
// Mit der Antwort des Servers weiterarbeiten, nicht mit dem eigenen
|
||
// Modell: sortiert und gerundet wird dort.
|
||
karte._modell = daten.zeilen;
|
||
zeichnePreise(karte);
|
||
preisHinweis(karte, "Gespeichert – " + daten.gespeichert + " Zeiträume.", "text-success");
|
||
} catch (fehler) {
|
||
preisHinweis(karte, fehler.message, "text-danger");
|
||
}
|
||
knopf.disabled = false;
|
||
}
|
||
|
||
async function preiseHolen(karte) {
|
||
preisHinweis(karte, "Wird geladen …");
|
||
try {
|
||
const antwort = await fetch("ajax/settings.php?action=list&reihe=" + encodeURIComponent(karte.dataset.reihe));
|
||
const daten = await antwort.json();
|
||
if (!antwort.ok) {
|
||
throw new Error(daten.error || "Fehler " + antwort.status);
|
||
}
|
||
karte._modell = daten.zeilen;
|
||
zeichnePreise(karte);
|
||
preisHinweis(karte, "");
|
||
} catch (fehler) {
|
||
preisHinweis(karte, fehler.message, "text-danger");
|
||
}
|
||
}
|
||
|
||
function preisKarteBinden(karte) {
|
||
karte.addEventListener("input", function (ereignis) {
|
||
const feld = ereignis.target.closest("[data-feld]");
|
||
if (!feld) return;
|
||
const zeile = karte._modell[parseInt(feld.closest(".preis-zeile").dataset.i, 10)];
|
||
if (feld.dataset.feld === "ab") {
|
||
if (!feld.value) return; // waehrend des Tippens ist das Feld kurz leer
|
||
zeile.ab = feld.value;
|
||
} else {
|
||
zeile[feld.dataset.feld] = isNaN(feld.valueAsNumber) ? 0 : feld.valueAsNumber;
|
||
}
|
||
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
|
||
});
|
||
|
||
// Ein geaenderter Stichtag verschiebt die Reihenfolge und damit die Enden
|
||
// aller Zeilen - aber erst, wenn das Feld fertig ist. Waehrend des Tippens
|
||
// neu zu zeichnen wuerde den Eingabefokus wegreissen.
|
||
karte.addEventListener("change", function (ereignis) {
|
||
if (ereignis.target.dataset.feld === "ab") {
|
||
zeichnePreise(karte);
|
||
}
|
||
});
|
||
|
||
karte.addEventListener("click", function (ereignis) {
|
||
if (ereignis.target.closest(".preis-neu")) {
|
||
preisNeueZeile(karte);
|
||
} else if (ereignis.target.closest(".preis-weg")) {
|
||
const i = parseInt(ereignis.target.closest(".preis-zeile").dataset.i, 10);
|
||
karte._modell.splice(i, 1);
|
||
zeichnePreise(karte);
|
||
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
|
||
} else if (ereignis.target.closest(".preis-zurueck")) {
|
||
preiseHolen(karte);
|
||
} else if (ereignis.target.closest(".preis-speichern")) {
|
||
preiseSenden(karte);
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
document.querySelectorAll(".preis-karte").forEach(function (karte) {
|
||
const daten = preisDaten[karte.dataset.reihe];
|
||
if (!daten) return;
|
||
karte._modell = sortiert(daten.zeilen);
|
||
zeichnePreise(karte);
|
||
preisKarteBinden(karte);
|
||
});
|
||
});
|