/*
* 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 = "
";
html += "
"
+ ""
+ ""
+ "
";
// 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 += "
";
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 += "
"
+ ""
+ "
"
+ ""
+ "" + feld.einheit + ""
+ "
";
});
html += "
";
html += ""
+ "
";
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 += "
"
+ ""
+ warnung.join(" ") + "
";
}
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);
});
});