/** * Editor und Uebersicht fuer die Automatiken. * * Der Editor arbeitet auf einem Modell und zeichnet daraus die Oberflaeche - * nicht umgekehrt. Frueher stand die erste Bedingungszeile als festes HTML im * PHP und alle weiteren wurden im JavaScript zusammengesetzt; beide Fassungen * liefen auseinander, und beim Speichern musste der Server aus durchnummerierten * Feldnamen wieder eine Struktur raten. * * Modell: * autoModel.groups = [ [ {state_id, operator, value}, ... ], ... ] * eine innere Liste ist ein UND-Block, die aeussere * verknuepft die Bloecke mit ODER * autoModel.actions = [ {command_id, params: {parameterID: wert}}, ... ] * * Der Geraetekatalog kommt zusammen mit dem Datensatz im selben Dokument * (siehe ajax/AutoAction.php), es gibt also keine Nachladerei je Zeile. */ let autoModel = null; let autoDevices = []; let autoLockouts = []; let autoRooms = []; // --- Katalog-Helfer ------------------------------------------------------ function deviceById(id) { return autoDevices.find(d => d.id === id) || null; } function stateById(id) { for (const dev of autoDevices) { const state = dev.states.find(s => s.id === id); if (state) return { device: dev, state: state }; } return null; } function commandById(id) { for (const dev of autoDevices) { const cmd = dev.commands.find(c => c.id === id); if (cmd) return { device: dev, command: cmd }; } return null; } function operatorLabel(state, op) { const treffer = state.operators.find(o => o.value === op); return treffer ? treffer.label : op; } /** * Eine neue Zeile beginnt ohne Geraet. * * Frueher stand dort das erste Geraet mit seinem ersten Messwert - nach dem * Geraete-Discovery also "Bad Links: Gesperrt = ", eine Bedingung, die * niemand gemeint hat und die man erst wegklicken muss. Ohne Vorbelegung * sagt die Zeile, was zu tun ist, und der Server lehnt eine unvollstaendige * Bedingung ohnehin ab. */ function leereBedingung() { return { state_id: 0, operator: "=", value: "" }; } function leereAktion() { return { command_id: 0, params: {} }; } // --- Bausteine fuer die Oberflaeche -------------------------------------- function optionListe(select, eintraege, gewaehlt) { select.innerHTML = ""; eintraege.forEach(e => select.appendChild(option(e, gewaehlt))); } function option(e, gewaehlt) { const opt = document.createElement("option"); opt.value = e.value; opt.text = e.text; if (String(e.value) === String(gewaehlt)) opt.selected = true; return opt; } /** * Geraeteliste gruppiert - nach Raum, wo zugeordnet, sonst nach Geraeteart. * * Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll..."), nicht * in Geraetearten. Der Raum steht aber nur da, wo er von Hand vergeben wurde * (siehe Zuordnung ueber das Zahnrad in der Karte "Automatismen"); solange * das niemand getan hat, bleibt die Gruppierung nach Art - besser als eine * einzige Gruppe "Nicht zugeordnet". */ function geraeteGruppen(geraete) { if (!geraete.some(d => d.room)) { return gruppiere(geraete, d => d.type, null, "Einzelne", true); } const gruppen = gruppiere(geraete, d => d.roomLabel, raumReihenfolge(), "Nicht zugeordnet", false); // Was noch keinen Raum hat, bleibt sonst ein einziger Eimer - solange erst // ein Teil zugeordnet ist, waeren das die meisten Geraete. Innerhalb des // Eimers gilt deshalb weiter die Ordnung nach Geraeteart. const letzte = gruppen[gruppen.length - 1]; if (!letzte || !letzte.titel.startsWith("Nicht zugeordnet")) return gruppen; gruppen.pop(); return gruppen.concat( gruppiere(letzte.geraete, d => d.type, null, "Einzelne", true) .map(g => ({ titel: "Ohne Raum · " + g.titel, geraete: g.geraete }))); } /** Reihenfolge der Raum-Ueberschriften, wie in rooms.php notiert. */ function raumReihenfolge() { return autoRooms.map(r => r.label).concat(["Nicht zugeordnet"]); } /** * Nach einem Merkmal gruppieren. * * `einzelneSammeln` entscheidet, was mit Gruppen aus einem einzigen Geraet * passiert. Bei Gerätearten sollen sie zusammenfallen - fuenfzehn * Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als die * flache Liste. Bei Raeumen nicht: "OG · Bad" ist auch mit einem einzigen * Geraet die Ueberschrift, die man sucht, und in der Wohnung haengt oft nur * ein Thermostat je Raum. */ function gruppiere(geraete, merkmal, reihenfolge, restTitel, einzelneSammeln) { const nach = new Map(); geraete.forEach(d => { const schluessel = merkmal(d) || restTitel; if (!nach.has(schluessel)) nach.set(schluessel, []); nach.get(schluessel).push(d); }); const titel = Array.from(nach.keys()); titel.sort((a, b) => { if (reihenfolge) { const ia = reihenfolge.indexOf(a), ib = reihenfolge.indexOf(b); if (ia !== ib) return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib); } return a.localeCompare(b, "de"); }); const gruppen = []; const rest = []; titel.forEach(t => { const liste = nach.get(t); if (!einzelneSammeln || liste.length > 1 || t === restTitel) { gruppen.push({ titel: t + " (" + liste.length + ")", geraete: liste }); } else { rest.push(liste[0]); } }); if (rest.length) { rest.sort((a, b) => a.label.localeCompare(b.label, "de")); gruppen.push({ titel: restTitel + " (" + rest.length + ")", geraete: rest }); } return gruppen; } /** * Geraeteauswahl mit Suchfeld. * * Bei ueber fuenfzig Geraeten hilft Tippen mehr als Blaettern - und es hilft * quer zur Gruppierung: "bad" findet das Thermostat im Bad und die Jalousie * "Bad Links", "jalousie" findet alle elf auf einmal. Gesucht wird deshalb * ueber Beschriftung und Geraeteart zugleich. * * Statt einer Auswahlliste steht ein Knopf in der Zeile; die Liste klappt * darunter ueber die volle Breite auf. Auf dem Handy ist das mehr Platz als * ein natives Auswahlfeld hergibt, und das Suchfeld waere darin ohnehin * nicht unterzubringen. */ function geraeteWaehler(geraete, aktuelleId, beiAuswahl) { const knopf = document.createElement("button"); knopf.type = "button"; knopf.className = "form-select text-start auto-geraet"; const dev = geraete.find(d => d.id === aktuelleId); knopf.textContent = dev ? dev.label : "Gerät wählen …"; if (!dev) knopf.classList.add("text-body-secondary"); knopf.onclick = () => oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl); return knopf; } /** Die aufgeklappte Liste - es ist immer hoechstens eine offen. */ function schliesseWaehler() { const offen = document.querySelector(".geraete-waehler"); if (offen) offen.remove(); } function oeffneWaehler(knopf, geraete, aktuelleId, beiAuswahl) { const schonOffen = knopf.nextElementSibling && knopf.nextElementSibling.classList.contains("geraete-waehler"); schliesseWaehler(); if (schonOffen) return; // zweiter Klick schliesst wieder const panel = document.createElement("div"); panel.className = "geraete-waehler border rounded p-2 mt-1"; const suche = document.createElement("input"); suche.type = "search"; suche.className = "form-control form-control-sm mb-2"; suche.placeholder = "Suchen – Name oder Geräteart"; const liste = document.createElement("div"); liste.className = "list-group list-group-flush"; liste.style.maxHeight = "15rem"; liste.style.overflowY = "auto"; panel.appendChild(suche); panel.appendChild(liste); // Hinter die ganze Zeile haengen, nicht in sie hinein: in einer // input-group stuende die Liste sonst als weitere Spalte daneben. knopf.closest(".input-group").after(panel); const eintrag = (d) => { const zeile = document.createElement("button"); zeile.type = "button"; zeile.className = "list-group-item list-group-item-action py-1" + (d.id === aktuelleId ? " active" : ""); zeile.innerHTML = "" + textSicher(d.label) + "" + "" + textSicher(d.type) + ""; zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); }; return zeile; }; const zeichne = () => { const suchtext = suche.value.trim().toLowerCase(); liste.innerHTML = ""; const passend = geraete.filter(d => !suchtext || d.label.toLowerCase().includes(suchtext) || d.type.toLowerCase().includes(suchtext)); if (!passend.length) { liste.innerHTML = "
Nichts gefunden.
"; return; } // Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen // dann quer ueber alle Arten, und die Art steht ohnehin an jeder Zeile. if (suchtext) { passend.forEach(d => liste.appendChild(eintrag(d))); return; } geraeteGruppen(passend).forEach(g => { const kopf = document.createElement("div"); kopf.className = "px-2 pt-2 pb-1 small fw-bold text-body-secondary"; kopf.textContent = g.titel; liste.appendChild(kopf); g.geraete.forEach(d => liste.appendChild(eintrag(d))); }); }; suche.oninput = zeichne; suche.onkeydown = e => { if (e.key === "Escape") { schliesseWaehler(); knopf.focus(); } if (e.key === "Enter") { e.preventDefault(); const erster = liste.querySelector("button.list-group-item"); if (erster) erster.click(); } }; zeichne(); suche.focus(); } // Klick daneben schliesst die Liste - sonst bliebe sie stehen, wenn man es // sich anders ueberlegt. document.addEventListener("click", e => { if (e.target.closest(".geraete-waehler") || e.target.closest(".form-select.text-start")) return; schliesseWaehler(); }); function textSicher(text) { return String(text).replace(/&/g, "&").replace(//g, ">"); } /** * Wertfeld passend zum Datentyp des Messwerts oder Parameters. * Liefert das fertige Element; der Aufrufer haengt seinen Listener an. */ function wertFeld(spec, wert) { let feld; if (spec.input === "bool") { feld = document.createElement("select"); feld.className = "form-select"; optionListe(feld, [{ value: "true", text: "JA" }, { value: "false", text: "NEIN" }], wert === "" ? "true" : wert); } else if (spec.input === "select") { // Die Auswahl kommt fertig aus dem Katalog: bei Messwerten steht der // Klartext auch als Wert, bei Geraeteparametern die Zahl dahinter. feld = document.createElement("select"); feld.className = "form-select"; optionListe(feld, spec.options.map(o => ({ value: o.value, text: o.label })), wert); } else { feld = document.createElement("input"); feld.className = "form-control"; feld.type = spec.input; if (spec.min !== undefined && spec.min !== null) feld.min = spec.min; if (spec.max !== undefined && spec.max !== null) feld.max = spec.max; feld.value = wert !== "" ? wert : spec.input === "time" ? "00:00" : spec.input === "date" ? new Date().toISOString().split("T")[0] : spec.input === "number" ? "0" : ""; } return feld; } /** Standardwert, wenn eine Zeile auf einen anderen Messwert umgestellt wird. */ function standardWert(spec) { if (spec.input === "bool") return "true"; if (spec.input === "select") return spec.options.length ? spec.options[0].value : ""; if (spec.input === "time") return "00:00"; if (spec.input === "date") return new Date().toISOString().split("T")[0]; if (spec.input === "number") return "0"; return ""; } // --- Ausloeser ----------------------------------------------------------- /** * Zeichnet alle UND-Bloecke. Jeder Block hat sein eigenes "+ Bedingung", denn * mit einem einzigen Knopf ganz unten kaeme man immer nur an den letzten * Block heran und koennte eine bestehende Alternative nie mehr ergaenzen. */ function renderConditions() { const ziel = document.getElementById("condBlocks"); ziel.innerHTML = ""; autoModel.groups.forEach((gruppe, gi) => { if (gi > 0) { const trenner = document.createElement("div"); trenner.className = "d-flex align-items-center my-2 text-body-secondary"; trenner.innerHTML = "
" + "ODER" + "
"; ziel.appendChild(trenner); } const block = document.createElement("div"); block.className = "border rounded p-2"; gruppe.forEach((bed, bi) => { if (bi > 0) { const und = document.createElement("div"); und.className = "small fw-bold text-body-secondary ms-1 my-1"; und.textContent = "und"; block.appendChild(und); } block.appendChild(conditionRow(gi, bi, bed)); }); const add = document.createElement("button"); add.type = "button"; add.className = "btn btn-outline-success btn-sm mt-2"; add.innerHTML = " Bedingung"; add.onclick = () => { gruppe.push(leereBedingung()); renderConditions(); }; block.appendChild(add); ziel.appendChild(block); }); renderSummary(); } function conditionRow(gi, bi, bed) { const zeile = document.createElement("div"); zeile.className = "input-group auto-zeile"; const treffer = stateById(bed.state_id); const dev = treffer ? treffer.device : null; const state = treffer ? treffer.state : null; // Geraet zeile.appendChild(geraeteWaehler( autoDevices.filter(d => d.states.length), dev ? dev.id : 0, neu => { bed.state_id = neu.states[0].id; bed.operator = neu.states[0].operators[0].value; bed.value = standardWert(neu.states[0]); renderConditions(); })); // Messwert - nur zeigen, wenn das Geraet mehr als einen hat if (dev && dev.states.length > 1) { const messwert = document.createElement("select"); messwert.className = "form-select auto-messwert"; optionListe(messwert, dev.states.map(s => ({ value: s.id, text: s.name + (s.value ? " (= " + s.value + ")" : "") })), bed.state_id); messwert.onchange = () => { const neu = dev.states.find(s => s.id === Number(messwert.value)); bed.state_id = neu.id; bed.operator = neu.operators[0].value; bed.value = standardWert(neu); renderConditions(); }; zeile.appendChild(messwert); } if (state) { // Operator. Eine Auswahlliste statt des frueheren Umschalt-Knopfes: der // zeigte nur seinen aktuellen Zustand, welche Vergleiche es sonst noch // gibt, fand man erst durch Klicken heraus. const op = document.createElement("select"); // Feste, schmale Breite: der Pfeil der Auswahlliste braucht Platz, sonst // schiebt er das Zeichen aus dem sichtbaren Bereich. op.className = "form-select flex-grow-0 text-center"; op.style.flex = "0 0 5.5rem"; optionListe(op, state.operators.map(o => ({ value: o.value, text: o.label })), bed.operator); op.onchange = () => { bed.operator = op.value; renderSummary(); }; zeile.appendChild(op); const wert = wertFeld(state, bed.value); wert.onchange = () => { bed.value = wert.value; renderSummary(); }; zeile.appendChild(wert); if (state.unit) { const einheit = document.createElement("span"); einheit.className = "input-group-text"; einheit.textContent = state.unit; zeile.appendChild(einheit); } } const weg = document.createElement("button"); weg.type = "button"; weg.className = "btn btn-outline-danger"; weg.innerHTML = ""; weg.onclick = () => { autoModel.groups[gi].splice(bi, 1); // Ein leerer Block hat keine Bedeutung mehr und verschwindet mit. if (!autoModel.groups[gi].length) autoModel.groups.splice(gi, 1); renderConditions(); }; zeile.appendChild(weg); return zeile; } /** * Derselbe Satz, den auch die Uebersicht und der Runner benutzen. Er steht * unter den Bloecken, damit man die Klammerung nicht aus der Optik erschliessen * muss, sondern schwarz auf weiss liest. */ function conditionText() { const teile = autoModel.groups .map(g => g.filter(bed => stateById(bed.state_id))) .filter(g => g.length) .map(gruppe => { const text = gruppe.map(bed => { const treffer = stateById(bed.state_id); let s = treffer.device.label + ": " + treffer.state.name + " " + operatorLabel(treffer.state, bed.operator) + " " + bed.value; if (treffer.state.unit) s += " " + treffer.state.unit; return s; }).join(" und "); return (autoModel.groups.length > 1 && gruppe.length > 1) ? "(" + text + ")" : text; }); return teile.join(" oder "); } function renderSummary() { const text = conditionText(); document.getElementById("condSummary").innerHTML = text ? "Löst aus, wenn " + text.replace(/&/g, "&").replace(/." : "Noch kein Auslöser – die Automatik würde nie starten."; } // --- Aktionen ------------------------------------------------------------ function renderActions() { const ziel = document.getElementById("actionList"); ziel.innerHTML = ""; autoModel.actions.forEach((aktion, ai) => ziel.appendChild(actionRow(ai, aktion))); } function actionRow(ai, aktion) { const zeile = document.createElement("div"); zeile.className = "input-group mb-2 auto-zeile"; const treffer = commandById(aktion.command_id); const dev = treffer ? treffer.device : null; const cmd = treffer ? treffer.command : null; zeile.appendChild(geraeteWaehler( autoDevices.filter(d => d.commands.length), dev ? dev.id : 0, neu => { aktion.command_id = neu.commands[0].id; aktion.params = standardParams(neu.commands[0]); renderActions(); })); if (dev && dev.commands.length > 1) { const befehl = document.createElement("select"); befehl.className = "form-select auto-messwert"; optionListe(befehl, dev.commands.map(c => ({ value: c.id, text: c.name })), aktion.command_id); befehl.onchange = () => { const neu = dev.commands.find(c => c.id === Number(befehl.value)); aktion.command_id = neu.id; aktion.params = standardParams(neu); renderActions(); }; zeile.appendChild(befehl); } // Je Parameter ein Feld - wie viele es sind, sagt das Geraet. Frueher gab es // vier feste Felder "Wert 1" bis "Wert 4", die je nach Kommando versteckt // wurden, ihren Inhalt aber trotzdem mitschickten. if (cmd) { cmd.params.forEach(p => { const beschriftung = document.createElement("span"); beschriftung.className = "input-group-text"; beschriftung.textContent = p.name; zeile.appendChild(beschriftung); const wert = wertFeld(p, aktion.params[p.id] !== undefined ? aktion.params[p.id] : standardWert(p)); wert.onchange = () => { aktion.params[p.id] = wert.value; }; zeile.appendChild(wert); }); } const weg = document.createElement("button"); weg.type = "button"; weg.className = "btn btn-outline-danger"; weg.innerHTML = ""; weg.onclick = () => { autoModel.actions.splice(ai, 1); renderActions(); }; zeile.appendChild(weg); return zeile; } function standardParams(cmd) { const params = {}; cmd.params.forEach(p => { params[p.id] = standardWert(p); }); return params; } // --- Rahmenbedingungen --------------------------------------------------- const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; function renderWeekdays() { const ziel = document.getElementById("weekdayGroup"); ziel.innerHTML = ""; WOCHENTAGE.forEach((tag, i) => { const feld = document.createElement("div"); feld.className = "input-group-text"; feld.innerHTML = "" + ""; feld.querySelector("input").onchange = e => { if (e.target.checked) autoModel.weekdays |= (1 << i); else autoModel.weekdays &= ~(1 << i); }; ziel.appendChild(feld); }); } function fuelleFormular() { const sperre = document.getElementById("lockoutSecs"); optionListe(sperre, autoLockouts.map(o => ({ value: o.value, text: o.label })), autoModel.lockout_secs); document.getElementById("autoName").value = autoModel.name; // Kennt die Liste die Etage nicht, bleibt eine Auswahlliste sonst leer // stehen und schickt beim Speichern "" mit. const etage = document.getElementById("autoFloor"); etage.value = autoModel.floor; if (etage.selectedIndex < 0) { etage.selectedIndex = 0; autoModel.floor = etage.value; } document.getElementById("tSpanFrom").value = autoModel.window_from; document.getElementById("tSpanTo").value = autoModel.window_to; document.getElementById("actFerien").checked = autoModel.on_vacation; document.getElementById("actFeier").checked = autoModel.on_holiday; document.getElementById("runOnce").checked = autoModel.force_once; renderWeekdays(); renderConditions(); renderActions(); } /** Die Felder, die direkt am Formular haengen, ins Modell zurueckholen. */ function leseFormular() { autoModel.name = document.getElementById("autoName").value.trim(); autoModel.floor = document.getElementById("autoFloor").value; autoModel.window_from = document.getElementById("tSpanFrom").value; autoModel.window_to = document.getElementById("tSpanTo").value; autoModel.on_vacation = document.getElementById("actFerien").checked; autoModel.on_holiday = document.getElementById("actFeier").checked; autoModel.force_once = document.getElementById("runOnce").checked; autoModel.lockout_secs = Number(document.getElementById("lockoutSecs").value); } // --- Modal --------------------------------------------------------------- function loadAutomatic() { const daten = JSON.parse(document.getElementById("autoActionData").textContent); autoDevices = daten.devices; autoLockouts = daten.lockouts; autoRooms = daten.rooms || []; const a = daten.automation; autoModel = { id: a.id, name: a.name, floor: a.floor, enabled: a.enabled, window_from: a.window_from, window_to: a.window_to, weekdays: a.weekdays, on_vacation: a.on_vacation, on_holiday: a.on_holiday, force_once: a.force_once, lockout_secs: a.lockout_secs, groups: [], actions: a.actions.map(x => ({ command_id: x.command_id, params: Object.assign({}, x.params) })) }; // Die flache Liste aus der Datenbank wieder in Bloecke fassen. Die // Gruppennummern kommen aufsteigend und dicht, siehe saveAutomation(). const nachGruppe = new Map(); a.conditions.forEach(c => { if (!nachGruppe.has(c.group)) nachGruppe.set(c.group, []); nachGruppe.get(c.group).push({ state_id: c.state_id, operator: c.operator, value: c.value }); }); autoModel.groups = Array.from(nachGruppe.values()); // Eine neue Automatik startet mit je einer leeren Zeile, sonst steht man // vor einem leeren Formular ohne erkennbaren Anfang. if (!autoModel.groups.length) autoModel.groups = [[leereBedingung()]]; if (!autoModel.actions.length) autoModel.actions = [leereAktion()]; fuelleFormular(); } function openAutoActionModal(params) { const contentURL = "./ajax/AutoAction.php" + params; document.getElementById("modal-title").innerHTML = params.search("id=") > 0 ? "Automatik bearbeiten" : "Neue Automatik anlegen"; // Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne // Zuhoerer stellt sicher, dass nicht auch noch der Handler eines vorher // geoeffneten Modals mitfeuert. const alt = document.getElementById("modalSaveBtn"); const btn = alt.cloneNode(true); alt.replaceWith(btn); btn.addEventListener("click", submitAutoAction); // Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder // her, damit die anderen Modals unveraendert aussehen. const dialog = document.querySelector("#modalEV .modal-dialog"); dialog.classList.add("modal-xl"); document.getElementById("modalEV").addEventListener("hidden.bs.modal", () => dialog.classList.remove("modal-xl"), { once: true }); const modalBodyElement = document.getElementById("modal-body"); modalBodyElement.innerHTML = loadingHTML("Wird geladen..."); modalEV.show(); fetch(contentURL, { method: "GET", headers: { "X-Requested-From-Modal": "a", "Requested-With-Ajax": "ajax" } }) .then(response => response.text()) .then(html => { modalBodyElement.innerHTML = html; loadAutomatic(); document.getElementById("btnAddGroup").addEventListener("click", () => { autoModel.groups.push([leereBedingung()]); renderConditions(); }); document.getElementById("btnAddActor").addEventListener("click", () => { autoModel.actions.push(leereAktion()); renderActions(); }); }) .catch(error => { modalBodyElement.innerHTML = "Konnte den Editor nicht laden: " + error.message; }); } function submitAutoAction() { leseFormular(); const nutzlast = { id: autoModel.id, name: autoModel.name, floor: autoModel.floor, enabled: autoModel.enabled, window_from: autoModel.window_from, window_to: autoModel.window_to, weekdays: autoModel.weekdays, on_vacation: autoModel.on_vacation, on_holiday: autoModel.on_holiday, force_once: autoModel.force_once, lockout_secs: autoModel.lockout_secs, conditions: [], actions: autoModel.actions }; autoModel.groups.forEach((gruppe, gi) => { gruppe.forEach(bed => { nutzlast.conditions.push({ group: gi, state_id: bed.state_id, operator: bed.operator, value: bed.value }); }); }); automationRequest("save", nutzlast).then(antwort => { if (antwort.error) { alert(antwort.error); return; } modalEV.hide(); refreshAutomations(nutzlast.floor); }); } // --- Uebersicht ---------------------------------------------------------- function automationRequest(action, body) { return fetch("./ajax/AutoAction.php?action=" + action, { method: "POST", headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" }, body: JSON.stringify(body) }) .then(response => response.json()) .catch(error => ({ error: error.message })); } /** Die Tabelle einer Etage neu holen. Ohne Etage alle drei. */ function refreshAutomations(floor) { const etagen = floor ? [floor] : ["UG", "EG", "OG"]; etagen.forEach(etage => { const ziel = document.getElementById("actions-" + etage + "-list"); if (!ziel) return; fetch("./ajax/AutoAction.php?action=list&floor=" + etage, { method: "GET", headers: { "Requested-With-Ajax": "ajax" } }) .then(response => response.text()) .then(html => { ziel.innerHTML = html; }) .catch(() => { ziel.innerHTML = "

Liste konnte nicht geladen werden.

"; }); }); } /** * Zuordnung Geraet -> Raum. Eigenes Modal, weil es kein Teil einer einzelnen * Automatik ist, sondern einmal fuer alle gilt. */ function openRoomModal() { document.getElementById("modal-title").innerHTML = "Geräte den Räumen zuordnen"; const alt = document.getElementById("modalSaveBtn"); const btn = alt.cloneNode(true); alt.replaceWith(btn); btn.addEventListener("click", speichereRaeume); const dialog = document.querySelector("#modalEV .modal-dialog"); dialog.classList.add("modal-lg"); document.getElementById("modalEV").addEventListener("hidden.bs.modal", () => dialog.classList.remove("modal-lg"), { once: true }); const koerper = document.getElementById("modal-body"); koerper.innerHTML = loadingHTML("Wird geladen..."); modalEV.show(); fetch("./ajax/AutoAction.php?action=rooms", { method: "GET", headers: { "Requested-With-Ajax": "ajax" } }) .then(a => a.text()) .then(html => { koerper.innerHTML = html; const tipps = document.getElementById("btnRoomTipps"); if (tipps) { tipps.addEventListener("click", () => { koerper.querySelectorAll("select[data-tipp]").forEach(s => { if (!s.value) s.value = s.dataset.tipp; }); tipps.disabled = true; }); } }) .catch(fehler => { koerper.innerHTML = "Konnte die Zuordnung nicht laden: " + fehler.message; }); } function speichereRaeume() { const rooms = {}; document.querySelectorAll("#modal-body select[data-actor]").forEach(s => { rooms[s.dataset.actor] = s.value; }); automationRequest("saveRooms", { rooms: rooms }).then(antwort => { if (antwort.error) alert(antwort.error); else modalEV.hide(); }); } function toggleAutomation(id) { automationRequest("toggle", { id: id }).then(antwort => { if (antwort.error) alert(antwort.error); else refreshAutomations(null); }); } function deleteAutomation(id, name) { if (!confirm("Automatik \"" + name + "\" wirklich löschen?")) return; automationRequest("delete", { id: id }).then(antwort => { if (antwort.error) alert(antwort.error); else refreshAutomations(null); }); } document.addEventListener("DOMContentLoaded", () => refreshAutomations(null));