/* * 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 += "
" + "" + "
" + "" + (bis === null ? "offen" : datumLesbar(bis)) + "" + (gilt ? "gilt heute" : "") + "
"; 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); }); });