Fuer die Symbole vor den Zahlen standen drei Codepunkte von Hand in rooms.php, und die Maske bot genau diese drei an. Jedes weitere Symbol waere eine Codeaenderung gewesen. Die Codepunkte stehen aber schon im Haus: css/bootstrap-icons.min.css fuehrt sie unter .bi-<name>::before - dieselbe Quelle, aus der auch der Browser liest. bootstrapIcons() liest sie von dort (2078 Stueck, einmal je Anfrage), kachelIcon() schlaegt darin nach. Damit ist jedes Symbol moeglich, und ein Versionswechsel der Icons zieht von selbst mit. In der Maske steht deshalb kein Auswahlfeld mehr, sondern der Name mit einer Vorschau davor - "moisture" sagt nichts, das Symbol daneben schon. Die Vorschlagsliste beginnt mit gut dreissig ueblichen Symbolen unter deutscher Beschriftung (Temperatur, Feuchte, Leistung, Wallbox, Regen ...), danach folgt der ganze Rest alphabetisch. Ein Name, den es nicht gibt, zeigt sofort ein rotes Fragezeichen und wird beim Speichern mit Begruendung abgelehnt. In der eingeklappten Kurzfassung einer Kachel steht das Symbol jetzt mit - es ist das schnellste Erkennungszeichen dafuer, welcher der drei Werte gemeint ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1121 lines
46 KiB
JavaScript
1121 lines
46 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);
|
||
});
|
||
});
|
||
|
||
/* ==========================================================================
|
||
Reiter "Home-Kacheln": welche Messwerte auf den Kacheln stehen.
|
||
|
||
Dieselbe Arbeitsteilung wie bei den Preisen: das Modell ist die Wahrheit,
|
||
jede Eingabe schreibt hinein, gezeichnet wird daraus neu. Anders als dort
|
||
kommt es nicht mit der Seite, sondern per Abruf - die Liste haengt an den
|
||
Raeumen und aendert sich seltener als ein Preis, aber sie ist laenger.
|
||
|
||
Eine Kachel ist entweder "Standard" (keine Zeile in der Datenbank, es gilt
|
||
restricted/rooms.php) oder "selbst gesetzt". Der Unterschied steht in der
|
||
Zeile, denn er entscheidet, was beim naechsten Umbau von rooms.php
|
||
passiert: Standardkacheln ziehen mit, selbst gesetzte nicht.
|
||
========================================================================== */
|
||
|
||
let kachelModell = null; // { kacheln, symbole, topics, max, tabelleDa }
|
||
|
||
/*
|
||
* Der Katalog ist die Liste der Messwerte, die der Geraetesuchlauf kennt -
|
||
* dieselbe, aus der der Automatik-Editor seine Bedingungen baut. Ein Wert
|
||
* daraus bringt Topic, JSON-Pfad und Einheit mit; von Hand einzutippen ist
|
||
* dann nichts mehr.
|
||
*
|
||
* Gespeichert wird trotzdem nur Topic und Pfad. Der Katalog ist eine
|
||
* Eingabehilfe und keine zweite Wahrheit: verschwindet ein Geraet aus dem
|
||
* Suchlauf, zeigt die Kachel weiter, was sie zeigt.
|
||
*/
|
||
|
||
/** Der Katalogeintrag zu einem Wert - oder null, wenn er dort nicht steht. */
|
||
function katalogTreffer(wert) {
|
||
if (!kachelModell || !kachelModell.katalog || wert.wechselrichter) return null;
|
||
const pfad = wert.pfad || "";
|
||
for (let gi = 0; gi < kachelModell.katalog.length; gi++) {
|
||
const werte = kachelModell.katalog[gi].werte;
|
||
for (let wi = 0; wi < werte.length; wi++) {
|
||
if (werte[wi].topic === wert.topic && (werte[wi].pfad || "") === pfad) {
|
||
return { gi: gi, wi: wi, geraet: kachelModell.katalog[gi], wert: werte[wi] };
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Welche Eingabeart eine Zeile zeigt.
|
||
*
|
||
* Beim ersten Zeichnen geraten: steht der Wert im Katalog, ist es eine
|
||
* Auswahl. Sobald der Benutzer die Art umstellt, gilt seine Wahl (_quelle) -
|
||
* sonst spraenge die Zeile beim naechsten Neuzeichnen zurueck.
|
||
*/
|
||
function kachelQuelle(wert) {
|
||
if (wert._quelle) return wert._quelle;
|
||
if (wert.wechselrichter) return "wr";
|
||
return katalogTreffer(wert) ? "sensor" : "topic";
|
||
}
|
||
|
||
/**
|
||
* Kurzform eines Wertes fuer die Zeile: das Wesentliche, nicht alles.
|
||
*
|
||
* Der Geraetename steht nur dabei, wenn das Geraet in einem anderen Raum haengt
|
||
* als die Kachel - im eigenen Raum wiederholte er nur, wo man ohnehin steht,
|
||
* und aus drei Kuerzeln wuerden drei Zeilen.
|
||
*/
|
||
function kachelWertKurz(wert, kachel) {
|
||
if (wert.wechselrichter) {
|
||
const n = wert.wechselrichter.length;
|
||
return "Wechselrichter (" + n + ")";
|
||
}
|
||
const treffer = katalogTreffer(wert);
|
||
const einheit = wert.format === "leistung" ? "W/kW" : (wert.einheit || "");
|
||
if (treffer) {
|
||
const fremd = !kachel || treffer.geraet.raum !== kachel.name;
|
||
return (fremd ? treffer.geraet.label + " · " : "") + treffer.wert.name
|
||
+ (einheit ? " " + einheit : "");
|
||
}
|
||
const topic = wert.topic || "";
|
||
const teile = topic.split("/");
|
||
let text = teile[teile.length - 1] || topic;
|
||
if (wert.pfad) text += " · " + wert.pfad;
|
||
return einheit ? text + " " + einheit : text;
|
||
}
|
||
|
||
/** Eine Kachel im Modell finden. */
|
||
function kachelSuchen(floor, room) {
|
||
return (kachelModell.kacheln || []).find(k => k.floor === floor && k.room === room);
|
||
}
|
||
|
||
/* --- Zeichnen ------------------------------------------------------------ */
|
||
|
||
function zeichneKacheln() {
|
||
const ziel = document.getElementById("kachelListe");
|
||
if (!ziel || !kachelModell) return;
|
||
|
||
if (!kachelModell.tabelleDa) {
|
||
ziel.innerHTML = '<div class="alert alert-warning mb-0">Die Tabelle '
|
||
+ '<code>kachelWerte</code> fehlt noch. Sie steht in '
|
||
+ '<code>solarLog_kacheln.sql</code> – bis dahin zeigen alle Kacheln '
|
||
+ 'ihre Vorgabe, und Speichern ist nicht möglich.</div>';
|
||
return;
|
||
}
|
||
|
||
// Vorschlagsliste einmal fuer alle Topic-Felder.
|
||
let html = '<datalist id="kachelTopics">'
|
||
+ (kachelModell.topics || []).map(t => '<option value="' + textSchuetzen(t) + '">').join("")
|
||
+ "</datalist>";
|
||
|
||
let etage = null;
|
||
(kachelModell.kacheln || []).forEach(function (k) {
|
||
if (k.floor !== etage) {
|
||
if (etage !== null) html += "</div>";
|
||
etage = k.floor;
|
||
html += '<div class="kachel-etage"><div class="kachel-etage-name">'
|
||
+ textSchuetzen(k.floorLabel) + "</div>";
|
||
}
|
||
html += kachelZeile(k);
|
||
});
|
||
if (etage !== null) html += "</div>";
|
||
|
||
ziel.innerHTML = html;
|
||
}
|
||
|
||
/** Eine Raumzeile: Name, Kurzfassung der Werte, darunter der Editor. */
|
||
function kachelZeile(k) {
|
||
const werte = k._entwurf || k.werte;
|
||
// Das Symbol steht auch in der Kurzfassung: es ist das, was auf der Kachel
|
||
// vor der Zahl steht, und damit der schnellste Weg zu erkennen, welcher der
|
||
// drei Werte gemeint ist.
|
||
const kurz = werte.length
|
||
? werte.map(w => '<span class="kachel-chip">'
|
||
+ (w.icon && kachelSymbolBekannt(w.icon)
|
||
? '<i class="bi bi-' + textSchuetzen(w.icon) + '"></i>' : "")
|
||
+ textSchuetzen(kachelWertKurz(w, k)) + "</span>").join("")
|
||
: '<span class="kachel-chip leer">ohne Messwert</span>';
|
||
|
||
let html = '<div class="kachel-zeile' + (k._offen ? " offen" : "") + '"'
|
||
+ ' data-floor="' + k.floor + '" data-room="' + k.room + '">'
|
||
+ '<button type="button" class="kachel-kopf">'
|
||
+ '<i class="bi bi-chevron-right pfeil"></i>'
|
||
+ '<span class="name">' + textSchuetzen(k.name) + "</span>"
|
||
+ '<span class="werte">' + kurz + "</span>"
|
||
+ (k.eigen ? '<span class="badge text-bg-secondary">selbst gesetzt</span>' : "")
|
||
+ "</button>";
|
||
|
||
if (k._offen) {
|
||
html += '<div class="kachel-editor">'
|
||
+ werte.map((w, i) => kachelWertZeile(w, i, k)).join("");
|
||
if (werte.length < (kachelModell.max || 3)) {
|
||
html += '<button type="button" class="btn btn-sm btn-outline-secondary kachel-plus">'
|
||
+ '<i class="bi bi-plus-lg me-1"></i>Wert hinzufügen</button>';
|
||
}
|
||
html += '<div class="kachel-fuss">'
|
||
+ '<button type="button" class="btn btn-primary btn-sm kachel-speichern">Speichern</button>'
|
||
+ '<button type="button" class="btn btn-outline-secondary btn-sm kachel-standard"'
|
||
+ (k.eigen ? "" : " disabled") + ">Standard</button>"
|
||
+ '<span class="kachel-hinweis small"></span>'
|
||
+ "</div></div>";
|
||
}
|
||
return html + "</div>";
|
||
}
|
||
|
||
/* --- Symbole ------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Das Feld für das Symbol: eine Vorschau und ein Name.
|
||
*
|
||
* Statt einer Auswahlliste mit einer Handvoll Symbole steht hier der Name des
|
||
* Bootstrap-Icons - damit sind alle gut zweitausend möglich, ohne dass für
|
||
* jedes neue eines im Code nachgetragen werden müsste. Die üblichen stehen in
|
||
* der Vorschlagsliste obenan, mit deutscher Beschriftung; wer ein anderes
|
||
* will, tippt und bekommt den Rest angeboten (icons.getbootstrap.com).
|
||
*
|
||
* Die Vorschau ist die eigentliche Hilfe: ein Name wie "moisture" sagt nichts,
|
||
* das Symbol daneben schon.
|
||
*/
|
||
function kachelSymbolFeld(icon) {
|
||
return '<span class="kw-symbol-schau">' + kachelSymbolVorschau(icon) + "</span>"
|
||
+ '<input type="text" class="form-control form-control-sm" data-feld="icon"'
|
||
+ ' list="kachelIcons" placeholder="Symbol" autocomplete="off"'
|
||
+ ' value="' + textSchuetzen(icon) + '">';
|
||
}
|
||
|
||
/** Der Inhalt der Vorschau: das Symbol, ein Strich, oder ein Fragezeichen. */
|
||
function kachelSymbolVorschau(icon) {
|
||
if (!icon) return '<i class="bi bi-dash" title="ohne Symbol"></i>';
|
||
if (!kachelSymbolBekannt(icon)) {
|
||
return '<i class="bi bi-question-circle unbekannt" title="Dieses Symbol gibt es nicht"></i>';
|
||
}
|
||
return '<i class="bi bi-' + textSchuetzen(icon) + '" title="' + textSchuetzen(icon) + '"></i>';
|
||
}
|
||
|
||
function kachelSymbolBekannt(icon) {
|
||
if (!kachelSymbolBekannt._satz) {
|
||
kachelSymbolBekannt._satz = new Set((kachelModell.symbole || []).map(s => s.name));
|
||
}
|
||
return kachelSymbolBekannt._satz.has(icon);
|
||
}
|
||
|
||
/**
|
||
* Die Vorschlagsliste steht einmal im Dokument, nicht in jeder Zeile.
|
||
*
|
||
* Zweitausend Einträge sind rund siebzig Kilobyte Markup; bei jedem
|
||
* Neuzeichnen einer Kachel neu zu bauen waere Verschwendung. Sie haengt
|
||
* deshalb am Ende des Dokuments und ueberlebt das Neuzeichnen der Liste.
|
||
*/
|
||
function kachelIconListeSicherstellen() {
|
||
if (document.getElementById("kachelIcons")) return;
|
||
const liste = document.createElement("datalist");
|
||
liste.id = "kachelIcons";
|
||
liste.innerHTML = (kachelModell.symbole || []).map(s => '<option value="' + s.name + '"'
|
||
+ (s.titel ? ' label="' + textSchuetzen(s.titel) + '"' : "") + ">").join("");
|
||
document.body.appendChild(liste);
|
||
}
|
||
|
||
/**
|
||
* Eine Wertdefinition als Formularzeile.
|
||
*
|
||
* Die Eingabeart entscheidet, welche Felder sichtbar sind: eine Auswahl aus
|
||
* dem Katalog braucht kein Topic, ein Wechselrichter keinen JSON-Pfad. Alles
|
||
* gleichzeitig anzubieten waere eine Einladung, es auch auszufuellen -
|
||
* gelesen wird immer nur eines davon.
|
||
*/
|
||
function kachelWertZeile(wert, i, kachel) {
|
||
const quelle = kachelQuelle(wert);
|
||
const katalog = (kachelModell && kachelModell.katalog) || [];
|
||
|
||
// Eingabeart. "Messwert" steht nur zur Wahl, wenn es einen Katalog gibt -
|
||
// ohne Mesh-Datenbank waere es eine leere Liste.
|
||
let felder = '<div class="kw-quelle">'
|
||
+ '<select class="form-select form-select-sm" data-feld="quelle">'
|
||
+ (katalog.length ? '<option value="sensor"' + (quelle === "sensor" ? " selected" : "")
|
||
+ ">Messwert</option>" : "")
|
||
+ '<option value="topic"' + (quelle === "topic" ? " selected" : "") + ">Topic</option>"
|
||
+ '<option value="wr"' + (quelle === "wr" ? " selected" : "") + ">Wechselrichter</option>"
|
||
+ "</select></div>";
|
||
|
||
if (quelle === "sensor") {
|
||
const treffer = katalogTreffer(wert);
|
||
// Ohne Treffer beim ersten Geraet des eigenen Raums beginnen, sonst beim
|
||
// ersten ueberhaupt - ein neuer Wert soll nicht im Nachbargeschoss starten.
|
||
const ersterEigener = katalog.findIndex(
|
||
(g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name);
|
||
const gi = treffer ? treffer.gi : Math.max(0, ersterEigener);
|
||
const wi = treffer ? treffer.wi : -1;
|
||
|
||
/*
|
||
* Die Geraete des eigenen Raums stehen oben in einer eigenen Gruppe - das
|
||
* ist fast immer das Gesuchte. Der Rest folgt nach Etagen, denn manche
|
||
* Kachel zeigt bewusst etwas Fremdes: der Carport die Wallbox, die
|
||
* Technik den Netzanschluss. Ganz wegzulassen waere deshalb zu streng.
|
||
*
|
||
* Der Wert einer Option bleibt der Platz im vollstaendigen Katalog, nicht
|
||
* in der gefilterten Liste - sonst zeigte die Auswahl nach dem Umsortieren
|
||
* auf ein anderes Geraet.
|
||
*/
|
||
const eigen = (g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name;
|
||
const option = (g, index) => '<option value="' + index + '"'
|
||
+ (index === gi ? " selected" : "") + ">"
|
||
+ textSchuetzen(g.label + (g.raum && !eigen(g) ? " – " + g.raum : "")) + "</option>";
|
||
|
||
let geraete = "";
|
||
const eigene = katalog.map((g, i2) => [g, i2]).filter(([g]) => eigen(g));
|
||
if (eigene.length) {
|
||
geraete += '<optgroup label="Dieser Raum">'
|
||
+ eigene.map(([g, i2]) => option(g, i2)).join("") + "</optgroup>";
|
||
}
|
||
let etage = null;
|
||
katalog.forEach(function (g, index) {
|
||
if (eigen(g)) return;
|
||
if (g.floor !== etage) {
|
||
if (etage !== null) geraete += "</optgroup>";
|
||
etage = g.floor;
|
||
geraete += '<optgroup label="' + textSchuetzen(etage || "ohne Raum") + '">';
|
||
}
|
||
geraete += option(g, index);
|
||
});
|
||
if (etage !== null) geraete += "</optgroup>";
|
||
|
||
const messwerte = (katalog[gi] || { werte: [] }).werte.map(function (w, index) {
|
||
// Der aktuelle Wert steht dabei: er beantwortet, ob man den richtigen
|
||
// Messwert erwischt hat, ohne die Kachel erst speichern zu muessen.
|
||
const jetzt = (w.wert === null || w.wert === undefined || w.wert === "")
|
||
? "" : " · " + w.wert + (w.einheit ? " " + w.einheit : "");
|
||
return '<option value="' + index + '"' + (index === wi ? " selected" : "") + ">"
|
||
+ textSchuetzen(w.name + jetzt) + "</option>";
|
||
}).join("");
|
||
|
||
felder += '<div class="kw-topic"><select class="form-select form-select-sm"'
|
||
+ ' data-feld="geraet">' + geraete + "</select></div>"
|
||
+ '<div class="kw-pfad"><select class="form-select form-select-sm" data-feld="messwert">'
|
||
+ (wi < 0 ? '<option value="-1" selected>– wählen –</option>' : "")
|
||
+ messwerte + "</select></div>";
|
||
} else if (quelle === "wr") {
|
||
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
|
||
+ ' data-feld="wechselrichter" placeholder="Namen, mit Komma getrennt"'
|
||
+ ' value="' + textSchuetzen((wert.wechselrichter || []).join(", ")) + '"></div>'
|
||
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
|
||
+ ' data-feld="feld" placeholder="Feld" value="' + textSchuetzen(wert.feld || "p_AC") + '"></div>';
|
||
} else {
|
||
felder += '<div class="kw-topic"><input type="text" class="form-control form-control-sm"'
|
||
+ ' data-feld="topic" list="kachelTopics" placeholder="MQTT-Topic"'
|
||
+ ' value="' + textSchuetzen(wert.topic || "") + '"></div>'
|
||
+ '<div class="kw-pfad"><input type="text" class="form-control form-control-sm"'
|
||
+ ' data-feld="pfad" placeholder="JSON-Pfad" value="' + textSchuetzen(wert.pfad || "") + '"></div>';
|
||
}
|
||
|
||
return '<div class="kachel-wert" data-i="' + i + '">' + felder
|
||
|
||
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
|
||
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
|
||
+ (wert.format === "leistung" ? " disabled" : "")
|
||
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
|
||
|
||
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
|
||
+ ' data-feld="stellen" min="0" max="3" placeholder="0"'
|
||
+ (wert.format === "leistung" ? " disabled" : "")
|
||
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
|
||
|
||
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
|
||
|
||
+ '<div class="kw-schalter">'
|
||
+ '<label title="Leistung: skaliert selbst zwischen W und kW">'
|
||
+ '<input type="checkbox" class="form-check-input" data-feld="format"'
|
||
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>"
|
||
+ '<label title="Die Zahl steht gross auf der Kachel">'
|
||
+ '<input type="checkbox" class="form-check-input" data-feld="gross"'
|
||
+ (wert.gross ? " checked" : "") + "> groß</label>"
|
||
+ '<button type="button" class="btn btn-sm btn-outline-danger kachel-weg"'
|
||
+ ' title="Wert entfernen"><i class="bi bi-trash"></i></button>'
|
||
+ "</div></div>";
|
||
}
|
||
|
||
/** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */
|
||
function textSchuetzen(s) {
|
||
const d = document.createElement("div");
|
||
d.textContent = String(s === undefined || s === null ? "" : s);
|
||
return d.innerHTML.replace(/"/g, """);
|
||
}
|
||
|
||
/* --- Bedienung ----------------------------------------------------------- */
|
||
|
||
function kachelHinweis(zeile, text, klasse) {
|
||
const feld = zeile.querySelector(".kachel-hinweis");
|
||
if (feld) {
|
||
feld.textContent = text;
|
||
feld.className = "kachel-hinweis small " + (klasse || "text-secondary");
|
||
}
|
||
}
|
||
|
||
/** Aus den Feldern einer Zeile die Wertdefinition zusammensetzen. */
|
||
function kachelWertLesen(feldZeile) {
|
||
const hol = (name) => {
|
||
const el = feldZeile.querySelector('[data-feld="' + name + '"]');
|
||
if (!el) return undefined;
|
||
return el.type === "checkbox" ? el.checked : el.value;
|
||
};
|
||
const wert = {};
|
||
const quelle = hol("quelle") || "topic";
|
||
wert._quelle = quelle; // die Wahl des Benutzers, nicht gespeichert
|
||
|
||
if (quelle === "wr") {
|
||
wert.wechselrichter = String(hol("wechselrichter") || "")
|
||
.split(",").map(t => t.trim()).filter(Boolean);
|
||
wert.feld = hol("feld") || "p_AC";
|
||
} else if (quelle === "sensor") {
|
||
// Topic und Pfad kommen aus dem Katalog, nicht aus einem Textfeld.
|
||
const g = ((kachelModell.katalog || [])[parseInt(hol("geraet"), 10)] || { werte: [] });
|
||
const w = g.werte[parseInt(hol("messwert"), 10)];
|
||
if (w) {
|
||
wert.topic = w.topic;
|
||
if (w.pfad) wert.pfad = w.pfad;
|
||
}
|
||
} else {
|
||
wert.topic = (hol("topic") || "").trim();
|
||
const pfad = (hol("pfad") || "").trim();
|
||
if (pfad) wert.pfad = pfad;
|
||
}
|
||
if (hol("format")) {
|
||
wert.format = "leistung";
|
||
} else {
|
||
const einheit = (hol("einheit") || "").trim();
|
||
if (einheit) wert.einheit = einheit;
|
||
const stellen = hol("stellen");
|
||
if (stellen !== "" && stellen !== undefined) wert.stellen = parseInt(stellen, 10);
|
||
}
|
||
const icon = (hol("icon") || "").trim();
|
||
if (icon) wert.icon = icon;
|
||
if (hol("gross")) wert.gross = true;
|
||
return wert;
|
||
}
|
||
|
||
/** Den Entwurf einer Kachel aus allen sichtbaren Feldern nachziehen. */
|
||
function kachelEntwurfLesen(zeile, k) {
|
||
k._entwurf = Array.from(zeile.querySelectorAll(".kachel-wert")).map(kachelWertLesen);
|
||
}
|
||
|
||
async function kachelSenden(zeile, k, werte) {
|
||
kachelHinweis(zeile, "Wird gespeichert …", "text-secondary");
|
||
try {
|
||
const antwort = await fetch("ajax/settings.php?action=kacheln-save", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ floor: k.floor, room: k.room, werte: werte }),
|
||
});
|
||
const d = await antwort.json();
|
||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||
k.werte = d.werte;
|
||
k.eigen = d.eigen;
|
||
delete k._entwurf;
|
||
zeichneKacheln();
|
||
// Nach dem Neuzeichnen ist die Zeile eine andere - der Hinweis gehoert
|
||
// an die neue.
|
||
const neu = document.querySelector('.kachel-zeile[data-floor="' + k.floor
|
||
+ '"][data-room="' + k.room + '"]');
|
||
if (neu && k._offen) kachelHinweis(neu, "Gespeichert.", "text-success");
|
||
} catch (e) {
|
||
kachelHinweis(zeile, e.message, "text-danger");
|
||
}
|
||
}
|
||
|
||
async function kachelnHolen() {
|
||
const ziel = document.getElementById("kachelListe");
|
||
if (!ziel) return;
|
||
try {
|
||
const antwort = await fetch("ajax/settings.php?action=kacheln", { cache: "no-store" });
|
||
const d = await antwort.json();
|
||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||
kachelModell = d;
|
||
delete kachelSymbolBekannt._satz; // neue Antwort, neue Namen
|
||
kachelIconListeSicherstellen();
|
||
zeichneKacheln();
|
||
} catch (e) {
|
||
ziel.innerHTML = '<div class="alert alert-danger mb-0">Kacheln nicht abrufbar: '
|
||
+ textSchuetzen(e.message) + "</div>";
|
||
}
|
||
}
|
||
|
||
function kachelnBinden() {
|
||
const liste = document.getElementById("kachelListe");
|
||
if (!liste) return;
|
||
|
||
liste.addEventListener("click", function (ereignis) {
|
||
const zeile = ereignis.target.closest(".kachel-zeile");
|
||
if (!zeile || !kachelModell) return;
|
||
const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room);
|
||
if (!k) return;
|
||
|
||
if (ereignis.target.closest(".kachel-kopf")) {
|
||
// Nur eine Kachel gleichzeitig offen: zwei Editoren nebeneinander
|
||
// fuellen den Bildschirm, und die Zeile darunter waere nicht mehr zu
|
||
// sehen. Ein Wechsel verwirft ungespeicherte Aenderungen nicht - der
|
||
// Entwurf bleibt am Modell haengen.
|
||
const wechsel = !k._offen;
|
||
kachelModell.kacheln.forEach(a => { a._offen = false; });
|
||
k._offen = wechsel;
|
||
if (wechsel && !k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte));
|
||
zeichneKacheln();
|
||
return;
|
||
}
|
||
if (ereignis.target.closest(".kachel-plus")) {
|
||
kachelEntwurfLesen(zeile, k);
|
||
k._entwurf.push((kachelModell.katalog || []).length
|
||
? { _quelle: "sensor", einheit: "" } // erst waehlen, dann feilen
|
||
: { topic: "", einheit: "" });
|
||
zeichneKacheln();
|
||
return;
|
||
}
|
||
if (ereignis.target.closest(".kachel-weg")) {
|
||
kachelEntwurfLesen(zeile, k);
|
||
k._entwurf.splice(parseInt(ereignis.target.closest(".kachel-wert").dataset.i, 10), 1);
|
||
zeichneKacheln();
|
||
return;
|
||
}
|
||
if (ereignis.target.closest(".kachel-speichern")) {
|
||
kachelEntwurfLesen(zeile, k);
|
||
kachelSenden(zeile, k, k._entwurf);
|
||
return;
|
||
}
|
||
if (ereignis.target.closest(".kachel-standard")) {
|
||
kachelSenden(zeile, k, null);
|
||
}
|
||
});
|
||
|
||
// Die Quelle und das Leistungsformat blenden Felder um - dafuer muss neu
|
||
// gezeichnet werden. Alles andere schreibt nur in den Entwurf.
|
||
liste.addEventListener("change", function (ereignis) {
|
||
const feld = ereignis.target.closest("[data-feld]");
|
||
const zeile = ereignis.target.closest(".kachel-zeile");
|
||
if (!feld || !zeile || !kachelModell) return;
|
||
const k = kachelSuchen(zeile.dataset.floor, zeile.dataset.room);
|
||
if (!k) return;
|
||
const name = feld.dataset.feld;
|
||
|
||
kachelEntwurfLesen(zeile, k);
|
||
|
||
/*
|
||
* Ein neu gewaehlter Messwert bringt seine Einheit mit - sonst stuende an
|
||
* einer Temperatur weiter "W". Beim Wechsel des Geraets faellt die alte
|
||
* Auswahl weg, dann ist erst einmal keiner gewaehlt.
|
||
*/
|
||
if (name === "geraet" || name === "messwert") {
|
||
const i = parseInt(feld.closest(".kachel-wert").dataset.i, 10);
|
||
const wert = k._entwurf[i];
|
||
const treffer = wert && katalogTreffer(wert);
|
||
if (treffer) {
|
||
wert.einheit = treffer.wert.einheit;
|
||
} else if (name === "geraet") {
|
||
delete wert.topic;
|
||
delete wert.pfad;
|
||
}
|
||
}
|
||
|
||
// Diese drei blenden Felder um; alles andere schreibt nur in den Entwurf.
|
||
if (name === "quelle" || name === "format" || name === "geraet" || name === "messwert") {
|
||
zeichneKacheln();
|
||
}
|
||
});
|
||
|
||
/*
|
||
* Die Vorschau folgt dem Tippen. Neu gezeichnet wird dafuer nicht - das
|
||
* naehme dem Feld nach jedem Buchstaben den Cursor.
|
||
*/
|
||
liste.addEventListener("input", function (ereignis) {
|
||
const feld = ereignis.target.closest('[data-feld="icon"]');
|
||
if (!feld) return;
|
||
const schau = feld.parentElement.querySelector(".kw-symbol-schau");
|
||
if (schau) schau.innerHTML = kachelSymbolVorschau(feld.value.trim());
|
||
});
|
||
|
||
const neuLaden = document.getElementById("kachelNeuLaden");
|
||
if (neuLaden) neuLaden.addEventListener("click", kachelnHolen);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
// Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer
|
||
// jede Leiste der Seite, nicht nur fuer diese.
|
||
kachelnBinden();
|
||
kachelnHolen();
|
||
});
|
||
|
||
|
||
/* ==========================================================================
|
||
Reiter "Geräte": Raumzuordnung, Karteileichen.
|
||
|
||
Die Zuordnung war ein Modal auf der Startseite - erreichbar über ein
|
||
Zahnradsymbol neben den Automatismen, also genau dort, wo man sie nicht
|
||
sucht. Sie ist eine Einstellung und steht deshalb jetzt hier, zusammen mit
|
||
dem, was ohne sie fehlte: seit wann ein Gerät nichts mehr meldet, in
|
||
welchen Automatiken es steckt und der Knopf, mit dem ein abgebautes Gerät
|
||
verschwindet.
|
||
|
||
Letzterer ist nötig, weil der Suchlauf nichts löscht - er legt an und
|
||
aktualisiert, damit die Automatiken ihre Zustands-Ids behalten. Ein Gerät,
|
||
das aus dem Haus fliegt, bleibt also stehen, bis es hier jemand entfernt.
|
||
========================================================================== */
|
||
|
||
let geraeteModell = null; // { geraete, raeume }
|
||
const geraeteSieb = { text: "", nurOffen: false };
|
||
|
||
/* --- Ableitungen --------------------------------------------------------- */
|
||
|
||
/**
|
||
* Wie alt die jüngste Meldung eines Geräts ist, in Worten.
|
||
*
|
||
* "alt" heißt: lange genug her, um aufzufallen. Ein Thermostat meldet
|
||
* stündlich - drei Tage Stille sind schon viel; eine Jalousie meldet nur,
|
||
* wenn sie fährt. Deshalb faellt nur auf, was über einen Monat still ist.
|
||
*
|
||
* Geräte ganz ohne Messwerte - die Somfy-Screens etwa, die nur Befehle
|
||
* entgegennehmen - haben nichts zu melden. Bei ihnen steht ein Strich und
|
||
* keine Warnung: sonst stünde die halbe Spalte in Alarmfarbe, ohne dass
|
||
* irgendetwas fehlt.
|
||
*/
|
||
function geraetAlter(g) {
|
||
const zeitpunkt = g.gesehen;
|
||
if (!g.messwerte) return { text: "–", alt: false };
|
||
if (!zeitpunkt) return { text: "nie", alt: true };
|
||
const d = new Date(String(zeitpunkt).replace(" ", "T"));
|
||
if (isNaN(d.getTime())) return { text: "–", alt: false };
|
||
const tage = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||
if (tage <= 0) return { text: "heute", alt: false };
|
||
if (tage === 1) return { text: "gestern", alt: false };
|
||
if (tage < 31) return { text: "vor " + tage + " Tagen", alt: tage > 30 };
|
||
const monate = Math.round(tage / 30);
|
||
return { text: "vor " + monate + " Monaten", alt: true };
|
||
}
|
||
|
||
/** Ein Gerät im Modell finden. */
|
||
function geraetSuchen(id) {
|
||
return (geraeteModell.geraete || []).find(g => g.id === id);
|
||
}
|
||
|
||
/** Was das Sieb übrig lässt. */
|
||
function geraeteGefiltert() {
|
||
const suche = geraeteSieb.text.trim().toLowerCase();
|
||
return (geraeteModell.geraete || []).filter(function (g) {
|
||
if (geraeteSieb.nurOffen && g.room) return false;
|
||
if (!suche) return true;
|
||
return (g.label + " " + g.type + " " + (g.roomLabel || "")).toLowerCase().indexOf(suche) >= 0;
|
||
});
|
||
}
|
||
|
||
/* --- Zeichnen ------------------------------------------------------------ */
|
||
|
||
function zeichneGeraete() {
|
||
const ziel = document.getElementById("geraeteListe");
|
||
if (!ziel || !geraeteModell) return;
|
||
|
||
const alle = geraeteModell.geraete || [];
|
||
const sichtbar = geraeteGefiltert();
|
||
const ohneRaum = alle.filter(g => !g.room).length;
|
||
const tipps = alle.filter(g => !g.room && g.tipp).length;
|
||
|
||
let html = '<p class="small text-body-secondary">Die Zuordnung ordnet die Geräteliste '
|
||
+ 'im Automatik-Editor und die Auswahl der Kachelwerte nach Räumen. Ein neuer '
|
||
+ 'Suchlauf legt Geräte an und frischt sie auf, löscht aber keine – '
|
||
+ 'abgebaute Geräte verschwinden nur hier.</p>';
|
||
|
||
// Sieb: bei über sechzig Geräten ist Scrollen keine Suche.
|
||
html += '<div class="geraete-sieb">'
|
||
+ '<input type="search" class="form-control form-control-sm" id="geraeteSuche"'
|
||
+ ' placeholder="Gerät, Art oder Raum suchen" value="' + textSchuetzen(geraeteSieb.text) + '">'
|
||
+ '<div class="form-check form-switch mb-0">'
|
||
+ '<input class="form-check-input" type="checkbox" id="geraeteNurOffen"'
|
||
+ (geraeteSieb.nurOffen ? " checked" : "") + ">"
|
||
+ '<label class="form-check-label small" for="geraeteNurOffen">nur ohne Raum</label>'
|
||
+ "</div></div>";
|
||
|
||
if (tipps) {
|
||
// Viele Geräte tragen ihren Raum im Topic (Raumtemp/OG/Bad/Temp[degC]).
|
||
// Das von Hand nachzuklicken ist stumpfe Arbeit für den Rechner.
|
||
html += '<div class="alert alert-secondary py-2 small d-flex justify-content-between'
|
||
+ ' align-items-center gap-2 mb-2"><span>' + tipps + " Gerät(e) ohne Raum verraten ihn"
|
||
+ ' im MQTT-Topic.</span><button type="button" class="btn btn-outline-secondary'
|
||
+ ' btn-sm text-nowrap" id="geraeteTipps">Vorschläge übernehmen</button></div>';
|
||
}
|
||
|
||
html += '<div class="geraete-tafel">'
|
||
+ '<table class="table table-sm table-hover align-middle mb-0"><thead><tr>'
|
||
+ "<th>Gerät</th>"
|
||
+ '<th class="d-none d-md-table-cell">Art</th>'
|
||
+ "<th>Raum</th>"
|
||
+ '<th class="d-none d-sm-table-cell">Gemeldet</th>'
|
||
+ '<th class="d-none d-lg-table-cell">Automatiken</th>'
|
||
+ '<th class="text-end"></th>'
|
||
+ "</tr></thead><tbody>";
|
||
|
||
html += sichtbar.length
|
||
? sichtbar.map(g => geraetZeile(g)).join("")
|
||
: '<tr><td colspan="6" class="text-body-secondary py-3">Nichts gefunden.</td></tr>';
|
||
|
||
html += "</tbody></table></div>"
|
||
+ '<div class="geraete-fuss small"><span class="text-body-secondary">'
|
||
+ alle.length + " Geräte, " + ohneRaum + " ohne Raum"
|
||
+ (sichtbar.length !== alle.length ? " · " + sichtbar.length + " angezeigt" : "")
|
||
+ '</span><span class="geraete-stand"></span></div>';
|
||
|
||
ziel.innerHTML = html;
|
||
}
|
||
|
||
/** Eine Zeile der Tafel. */
|
||
function geraetZeile(g) {
|
||
const alter = geraetAlter(g);
|
||
const benutzt = g.benutzt || [];
|
||
|
||
// Die Raumauswahl nach Etagen gruppiert - dieselbe Reihenfolge wie überall,
|
||
// sie kommt schon sortiert aus raumListe().
|
||
let auswahl = '<option value=""' + (g.room ? "" : " selected") + ">–</option>";
|
||
let etage = null;
|
||
(geraeteModell.raeume || []).forEach(function (rm) {
|
||
if (rm.floor !== etage) {
|
||
if (etage !== null) auswahl += "</optgroup>";
|
||
etage = rm.floor;
|
||
auswahl += '<optgroup label="' + textSchuetzen(rm.floor) + '">';
|
||
}
|
||
auswahl += '<option value="' + textSchuetzen(rm.key) + '"'
|
||
+ (g.room === rm.key ? " selected" : "") + ">"
|
||
+ textSchuetzen(rm.room) + "</option>";
|
||
});
|
||
if (etage !== null) auswahl += "</optgroup>";
|
||
|
||
// Der Papierkorb steht sechzigmal untereinander - in Rot waere die Tafel
|
||
// eine Warnleuchte. Er faerbt sich erst, wenn man ihn anfasst.
|
||
const weg = benutzt.length
|
||
? '<button type="button" class="btn btn-sm geraet-weg" disabled'
|
||
+ ' title="Steckt in einer Automatik"><i class="bi bi-trash3"></i></button>'
|
||
: '<button type="button" class="btn btn-sm geraet-weg"'
|
||
+ ' title="Gerät entfernen"><i class="bi bi-trash3"></i></button>';
|
||
|
||
return '<tr data-id="' + g.id + '"' + (g.room ? "" : ' class="ohne-raum"') + ">"
|
||
+ '<td class="name">' + textSchuetzen(g.label)
|
||
/*
|
||
* Unter der Beschriftung steht genau eine Zusatzzeile - schmal wird die
|
||
* Art dort gezeigt, weil ihre Spalte weg ist; breit die Anzahl der
|
||
* Messwerte und Kommandos, die vor dem Loeschen interessiert. Nie beides:
|
||
* auf dem Handy braeuchte das drei Zeilen und erschluege den Namen.
|
||
*/
|
||
+ '<span class="neben">'
|
||
+ '<span class="d-md-none">' + textSchuetzen(g.type) + "</span>"
|
||
+ '<span class="d-none d-md-inline">' + g.messwerte + " Messwerte · "
|
||
+ g.kommandos + " Kommandos</span></span></td>"
|
||
+ '<td class="d-none d-md-table-cell small text-body-secondary">'
|
||
+ textSchuetzen(g.type) + "</td>"
|
||
+ '<td class="raum"><select class="form-select form-select-sm" data-actor="' + g.id + '"'
|
||
+ (g.tipp ? ' data-tipp="' + textSchuetzen(g.tipp) + '"' : "") + ">"
|
||
+ auswahl + "</select></td>"
|
||
+ '<td class="d-none d-sm-table-cell small alter' + (alter.alt ? " lange" : "") + '">'
|
||
+ alter.text + "</td>"
|
||
+ '<td class="d-none d-lg-table-cell small">'
|
||
+ (benutzt.length
|
||
? '<span class="badge text-bg-secondary" title="' + textSchuetzen(benutzt.join(", ")) + '">'
|
||
+ benutzt.length + "</span>"
|
||
: '<span class="text-body-secondary">–</span>')
|
||
+ "</td>"
|
||
+ '<td class="text-end">' + weg + "</td></tr>";
|
||
}
|
||
|
||
/** Kurze Rückmeldung im Fuß der Tafel; sie verblasst von selbst. */
|
||
function geraeteStand(text, klasse) {
|
||
const ziel = document.querySelector("#geraeteListe .geraete-stand");
|
||
if (!ziel) return;
|
||
ziel.className = "geraete-stand " + (klasse || "text-body-secondary");
|
||
ziel.textContent = text;
|
||
clearTimeout(geraeteStand._uhr);
|
||
if (klasse !== "text-danger") {
|
||
geraeteStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000);
|
||
}
|
||
}
|
||
|
||
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
|
||
|
||
async function geraeteFrage(action, koerper) {
|
||
const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(koerper),
|
||
} : { cache: "no-store" });
|
||
const d = await antwort.json();
|
||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||
return d;
|
||
}
|
||
|
||
/**
|
||
* Eine oder mehrere Zuordnungen speichern.
|
||
*
|
||
* Gespeichert wird sofort beim Umstellen und ohne die Tafel neu zu zeichnen:
|
||
* wer sich durch sechzig Geräte arbeitet, will nicht nach jedem Klick seinen
|
||
* Platz in der Liste wiederfinden. Nur die Zeilenfarbe wandert mit.
|
||
*/
|
||
async function geraeteRaeumeSenden(zuordnung) {
|
||
const ids = Object.keys(zuordnung);
|
||
try {
|
||
await geraeteFrage("geraete-raeume", { rooms: zuordnung });
|
||
ids.forEach(function (id) {
|
||
const g = geraetSuchen(parseInt(id, 10));
|
||
if (g) {
|
||
g.room = zuordnung[id] || null;
|
||
const raum = (geraeteModell.raeume || []).find(r => r.key === g.room);
|
||
g.roomLabel = raum ? raum.label : null;
|
||
}
|
||
const zeile = document.querySelector('#geraeteListe tr[data-id="' + id + '"]');
|
||
if (zeile) zeile.classList.toggle("ohne-raum", !zuordnung[id]);
|
||
});
|
||
geraeteStand(ids.length > 1 ? ids.length + " Zuordnungen gespeichert." : "Gespeichert.",
|
||
"text-success");
|
||
} catch (e) {
|
||
geraeteStand(e.message, "text-danger");
|
||
zeichneGeraete(); // Anzeige zurück auf das, was wirklich gilt
|
||
}
|
||
}
|
||
|
||
/** Ein Gerät entfernen - endgültig, deshalb mit Nachfrage. */
|
||
async function geraetLoeschenFragen(id) {
|
||
const g = geraetSuchen(id);
|
||
if (!g) return;
|
||
if (!confirm("Gerät \"" + g.label + "\" mit " + g.messwerte + " Messwerten und "
|
||
+ g.kommandos + " Kommandos wirklich löschen?\n\n"
|
||
+ "Ein neuer Suchlauf legt es wieder an, wenn es noch antwortet.")) {
|
||
return;
|
||
}
|
||
try {
|
||
const d = await geraeteFrage("geraet-loeschen", { id: id });
|
||
geraeteModell.geraete = d.geraete;
|
||
zeichneGeraete();
|
||
geraeteStand("„" + d.geloescht + "“ entfernt.", "text-success");
|
||
} catch (e) {
|
||
geraeteStand(e.message, "text-danger");
|
||
}
|
||
}
|
||
|
||
async function geraeteHolen() {
|
||
const ziel = document.getElementById("geraeteListe");
|
||
if (!ziel) return;
|
||
try {
|
||
geraeteModell = await geraeteFrage("geraete");
|
||
zeichneGeraete();
|
||
} catch (e) {
|
||
ziel.innerHTML = '<div class="alert alert-danger mb-0">Geräte nicht abrufbar: '
|
||
+ textSchuetzen(e.message) + "</div>";
|
||
}
|
||
}
|
||
|
||
/* --- Bedienung ----------------------------------------------------------- */
|
||
|
||
function geraeteBinden() {
|
||
const liste = document.getElementById("geraeteListe");
|
||
if (!liste) return;
|
||
|
||
liste.addEventListener("change", function (ereignis) {
|
||
if (ereignis.target.id === "geraeteNurOffen") {
|
||
geraeteSieb.nurOffen = ereignis.target.checked;
|
||
zeichneGeraete();
|
||
// Der Schalter ist nach dem Neuzeichnen ein anderes Element - ohne das
|
||
// hier verlöre die Tastatur nach dem Umlegen ihren Platz.
|
||
const schalter = document.getElementById("geraeteNurOffen");
|
||
if (schalter) schalter.focus();
|
||
return;
|
||
}
|
||
const wahl = ereignis.target.closest("select[data-actor]");
|
||
if (wahl) {
|
||
const zuordnung = {};
|
||
zuordnung[wahl.dataset.actor] = wahl.value;
|
||
geraeteRaeumeSenden(zuordnung);
|
||
}
|
||
});
|
||
|
||
liste.addEventListener("input", function (ereignis) {
|
||
if (ereignis.target.id !== "geraeteSuche") return;
|
||
geraeteSieb.text = ereignis.target.value;
|
||
zeichneGeraete();
|
||
// Nach dem Neuzeichnen ist das Feld ein anderes - der Cursor gehört zurück.
|
||
const feld = document.getElementById("geraeteSuche");
|
||
if (feld) {
|
||
feld.focus();
|
||
feld.setSelectionRange(feld.value.length, feld.value.length);
|
||
}
|
||
});
|
||
|
||
liste.addEventListener("click", function (ereignis) {
|
||
if (ereignis.target.closest("#geraeteTipps")) {
|
||
// Alle offenen Vorschläge auf einmal - eine Anfrage, ein Neuzeichnen.
|
||
const zuordnung = {};
|
||
liste.querySelectorAll("select[data-tipp]").forEach(function (s) {
|
||
if (!s.value) {
|
||
s.value = s.dataset.tipp;
|
||
zuordnung[s.dataset.actor] = s.value;
|
||
}
|
||
});
|
||
if (Object.keys(zuordnung).length) {
|
||
geraeteRaeumeSenden(zuordnung).then(zeichneGeraete);
|
||
}
|
||
return;
|
||
}
|
||
const weg = ereignis.target.closest(".geraet-weg");
|
||
if (weg) {
|
||
geraetLoeschenFragen(parseInt(weg.closest("tr").dataset.id, 10));
|
||
}
|
||
});
|
||
|
||
const neuLaden = document.getElementById("geraeteNeuLaden");
|
||
if (neuLaden) neuLaden.addEventListener("click", geraeteHolen);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
geraeteBinden();
|
||
geraeteHolen();
|
||
});
|