/** * 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 fuer die Auswahl: eine Ueberschrift je Etage, darin nach Raum * sortiert. * * Beim Anlegen einer Automatik denkt man in Raeumen ("im Bad soll ..."), eine * Ueberschrift je Raum waeren aber zwanzig Stueck - in einer Liste, die * fuenfzehn Zeilen hoch ist, saehe man dann mehr Ueberschrift als Geraet, und * "OG · Bad", "EG · Bad", "UG · Bad" schrieben die Etage immer wieder * dazu. Deshalb gruppiert nur die Etage; der Raum steht klein an jeder Zeile, * und weil danach sortiert wird, stehen die Geraete eines Raums trotzdem * beisammen. * * Der Raum steht nur da, wo er von Hand vergeben wurde (Zuordnung ueber das * Zahnrad in der Karte "Automatismen"). Solange das niemand getan hat, gaebe * die Etage nichts her - dann bleibt es bei der Ordnung nach Geraeteart. */ function geraeteGruppen(geraete) { if (!geraete.some(d => d.room)) { return nachGeraeteart(geraete); } const etagen = etagenReihenfolge(); // "Ohne Raum" und "Unbekannter Raum" stehen in keiner Raumliste. Ihr // indexOf() waere -1 und damit ganz vorne; sie gehoeren aber ans Ende. const platz = etage => etagen.includes(etage) ? etagen.indexOf(etage) : etagen.length; const nach = new Map(); geraete.forEach(d => { if (!nach.has(d.floorLabel)) nach.set(d.floorLabel, []); nach.get(d.floorLabel).push(d); }); return Array.from(nach.keys()) .sort((a, b) => platz(a) - platz(b) || a.localeCompare(b, "de")) .map(etage => { const liste = nach.get(etage); liste.sort((a, b) => raumIndex(a.room) - raumIndex(b.room) || a.type.localeCompare(b.type, "de") || a.label.localeCompare(b.label, "de")); return { titel: etage + " (" + liste.length + ")", geraete: liste }; }); } /** * Reihenfolge der Etagen-Ueberschriften, wie rooms.php sie notiert - erst OG, * dann EG, dann UG, dann Aussen. */ function etagenReihenfolge() { const etagen = []; autoRooms.forEach(r => { if (!etagen.includes(r.floor)) etagen.push(r.floor); }); return etagen; } /** * Platz eines Raums in rooms.php - die Sortierung innerhalb einer Etage. * * Alles ohne Raum bekommt denselben letzten Platz; dort entscheidet dann die * Geraeteart, sonst stuenden Jalousie, Steckdose und Thermostat wild * durcheinander. */ function raumIndex(key) { const i = autoRooms.findIndex(r => r.key === key); return i < 0 ? autoRooms.length : i; } /** * Was klein hinter dem Geraetenamen steht. * * Unter einer Etagen-Ueberschrift genuegt der Raum. In der Trefferliste einer * Suche steht keine Ueberschrift darueber, dort muss die Etage mit. Wo kein * Raum vergeben ist, steht die Geraeteart - die ist immer da und sagt * wenigstens, was fuer ein Ding das ist. */ function zeilenZusatz(d, mitEtage) { if (!d.roomName) return d.type; return mitEtage ? d.roomLabel : d.roomName; } /** * Worueber die Suche laeuft: Name, Raum und Geraeteart in einem Zug. * * Der Raum gehoert dazu, seit die Liste nach Etagen gruppiert - "bad" soll * auch den Handtuchtrockner finden, der im Bad haengt, ohne es im Namen zu * tragen. Ohne Zuordnung faellt der Raum weg; "Nicht zugeordnet" als * Suchbegriff waere kein Fund, sondern ein Versehen. */ function suchfeld(d) { return (d.label + " " + (d.roomName ? d.roomLabel : "") + " " + d.type).toLowerCase(); } /** * Nach Geraeteart gruppieren - der Rueckfall, solange kein Raum vergeben ist. * * Gruppen aus einem einzigen Geraet fallen dabei unter "Einzelne" zusammen: * fuenfzehn Ueberschriften mit je einem Eintrag waeren unuebersichtlicher als * die flache Liste darunter. */ function nachGeraeteart(geraete) { const nach = new Map(); geraete.forEach(d => { if (!nach.has(d.type)) nach.set(d.type, []); nach.get(d.type).push(d); }); const gruppen = []; const rest = []; Array.from(nach.keys()).sort((a, b) => a.localeCompare(b, "de")).forEach(art => { const liste = nach.get(art); if (liste.length > 1) gruppen.push({ titel: art + " (" + 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: "Einzelne (" + 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, die Jalousie * "Bad Links" und alles andere, was einem Bad zugeordnet ist, "jalousie" * findet alle elf auf einmal. Gesucht wird deshalb ueber Beschriftung, Raum * 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, Raum 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, mitEtage) => { const zeile = document.createElement("button"); zeile.type = "button"; // Der Zusatz steht rechts am Rand statt gleich hinter dem Namen: so // stehen die Raeume untereinander in einer Spalte und lassen sich // ueberfliegen, statt bei jeder Zeile woanders anzufangen. zeile.className = "list-group-item list-group-item-action py-1" + " d-flex justify-content-between align-items-center gap-2" + (d.id === aktuelleId ? " active" : ""); zeile.innerHTML = "" + textSicher(d.label) + "" + "" + textSicher(zeilenZusatz(d, mitEtage)) + ""; zeile.onclick = () => { schliesseWaehler(); beiAuswahl(d); }; return zeile; }; const zeichne = () => { const suchtext = suche.value.trim().toLowerCase(); liste.innerHTML = ""; const passend = geraete.filter(d => !suchtext || suchfeld(d).includes(suchtext)); if (!passend.length) { liste.innerHTML = "
Nichts gefunden.
"; return; } // Beim Suchen die Zwischenueberschriften weglassen: die Treffer stehen // dann quer ueber alle Etagen. Dafuer traegt jede Zeile die Etage mit - // "Bad" allein sagt sonst nicht, welches der drei Baeder gemeint ist. if (suchtext) { passend.forEach(d => liste.appendChild(eintrag(d, true))); 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, false))); }); }; 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); // Das Raum-Modal versteckt ihn; hier wird er gebraucht. btn.hidden = false; 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); // Das Raum-Modal versteckt ihn; hier wird er gebraucht. btn.hidden = false; 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));