257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
// Kleine, abhängigkeitsfreie Sortier-/Such-/Filterfunktion für Tabellen
|
|
// (GUI-Umbau 10./11.09.2026) - reines Vanilla-JS, kein Framework im Einsatz
|
|
// (Pico bringt keins mit, und für ein paar Dutzend Zeilen braucht es keins).
|
|
//
|
|
// Sortierung: Klick auf eine <th data-sort> togglet aufsteigend/absteigend,
|
|
// erkennt Zahlen automatisch (numerische statt alphabetische Sortierung).
|
|
//
|
|
// Suche: ein <input data-table-search="#tabellen-id"> filtert Zeilen per
|
|
// Volltextsuche über alle Zellen.
|
|
//
|
|
// Spalten-Filter (11.09.2026, Nutzer-Wunsch "wie Excels Autofilter"): ein
|
|
// <th data-filter> bekommt automatisch ein kleines Trichter-Symbol - Klick
|
|
// öffnet ein Dropdown mit Checkboxen für jeden in dieser Spalte tatsächlich
|
|
// vorkommenden Wert (kein Server-Code nötig, rein aus den gerenderten
|
|
// Zellen ermittelt). Ersetzt das ältere, serverseitig befüllte
|
|
// data-table-category-filter-Muster vollständig - ein Attribut auf dem
|
|
// <th> statt eines eigenen Werte-Blocks im Template. Wie bei Excel hat
|
|
// konsequent JEDE sortierbare Spalte auch einen Filter, unabhängig davon,
|
|
// wie sinnvoll das im Einzelfall ist (z.B. eine eindeutige Hostname-
|
|
// Spalte) - Konsistenz war der explizite Nutzer-Wunsch.
|
|
//
|
|
// Suche und Spalten-Filter kombinieren sich (alle müssen "durchlassen",
|
|
// damit eine Zeile sichtbar bleibt) - dafür trägt jede Zeile pro Filterart
|
|
// einen eigenen "versteckt"-Grund in ihrem dataset, row.hidden ist das
|
|
// Oder aller Gründe, nicht der zuletzt gesetzte Filter allein.
|
|
//
|
|
// Aktiver Filter (11.09.2026, Nutzer-Wunsch): solange ein Spalten-Filter
|
|
// tatsächlich einschränkt (nicht mehr alle Werte angehakt sind), bekommt
|
|
// das <th> die Klasse .spalten-gefiltert (sanfte Akzentfarbe, siehe
|
|
// base.html) - Trichter-Icon UND Spaltentitel färben sich mit
|
|
// (fill="currentColor" im SVG übernimmt automatisch dieselbe Farbe).
|
|
|
|
// Filter-Menü (22.10.2026, Nutzer-Wunsch): oben „Aufsteigend“/„Absteigend“
|
|
// sortieren und „Alle an“/„Alle aus“. Das Menü wird beim Öffnen fest am
|
|
// Fenster ausgerichtet wie die Aktionen-Menüs (dropdown-platz.js): als
|
|
// absolut positioniertes Kind der Kopfzelle schnitt es eine seitlich
|
|
// scrollende Tabelle ab (z. B. Merkmale bearbeiten, Spalte Key).
|
|
const FILTER_RAND = 8;
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
document.querySelectorAll("table").forEach((table) => {
|
|
const headerZeile = table.querySelector("thead tr");
|
|
if (!headerZeile) return;
|
|
|
|
Array.from(headerZeile.children).forEach((th, spaltenIndex) => {
|
|
if ("sort" in th.dataset) {
|
|
th.style.cursor = "pointer";
|
|
th.addEventListener("click", (e) => {
|
|
if (e.target.closest(".spalten-filter, .spalten-filter-menu")) return;
|
|
sortiereTabelle(table, th, spaltenIndex);
|
|
});
|
|
}
|
|
if ("filter" in th.dataset) {
|
|
baueSpaltenFilter(table, th, spaltenIndex);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-table-search]").forEach((input) => {
|
|
const table = document.querySelector(input.dataset.tableSearch);
|
|
if (!table) return;
|
|
input.addEventListener("input", () => filtereTabelle(table, input.value));
|
|
});
|
|
|
|
// Offene Filter-Dropdowns bei Klick ausserhalb schliessen.
|
|
document.addEventListener("click", (e) => {
|
|
if (e.target.closest(".spalten-filter, .spalten-filter-menu")) return;
|
|
document.querySelectorAll(".spalten-filter-menu").forEach((menu) => (menu.hidden = true));
|
|
});
|
|
|
|
// Offenes Menü zieht beim Scrollen und bei geänderter Fenstergröße mit;
|
|
// Scrollen im Menü selbst löst nichts aus.
|
|
const offeneMenues = () => document.querySelectorAll(".spalten-filter-menu:not([hidden])");
|
|
document.addEventListener("scroll", (e) => {
|
|
offeneMenues().forEach((menu) => {
|
|
if (e.target instanceof Node && menu.contains(e.target)) return;
|
|
richteFilterMenuAus(menu);
|
|
});
|
|
}, true);
|
|
window.addEventListener("resize", () => offeneMenues().forEach(richteFilterMenuAus));
|
|
});
|
|
|
|
function sortiereTabelle(table, th, spaltenIndex, richtung) {
|
|
const tbody = table.querySelector("tbody");
|
|
const zeilen = Array.from(tbody.querySelectorAll("tr"));
|
|
// Ohne Richtung (Klick auf den Spaltentitel) wird umgeschaltet.
|
|
const aufsteigend = richtung ? richtung === "asc" : th.dataset.sortDir !== "asc";
|
|
|
|
table.querySelectorAll("th[data-sort]").forEach((h) => delete h.dataset.sortDir);
|
|
th.dataset.sortDir = aufsteigend ? "asc" : "desc";
|
|
|
|
zeilen.sort((a, b) => {
|
|
// GUI-Runde 09.10.2026: Zellen mit data-sort-wert (z. B. Datum als
|
|
// ISO-Zeitstempel hinter "09.10.2026, 11:20") sortieren nach diesem
|
|
// Wert statt nach dem angezeigten Text.
|
|
const as = sortWert(a, spaltenIndex);
|
|
const bs = sortWert(b, spaltenIndex);
|
|
if (as !== undefined && bs !== undefined) {
|
|
const v = as < bs ? -1 : as > bs ? 1 : 0;
|
|
return aufsteigend ? v : -v;
|
|
}
|
|
const av = zellText(a, spaltenIndex);
|
|
const bv = zellText(b, spaltenIndex);
|
|
const an = parseFloat(av.replace(",", "."));
|
|
const bn = parseFloat(bv.replace(",", "."));
|
|
let vergleich;
|
|
if (!isNaN(an) && !isNaN(bn) && av.trim() !== "" && bv.trim() !== "") {
|
|
vergleich = an - bn;
|
|
} else {
|
|
vergleich = av.localeCompare(bv, "de");
|
|
}
|
|
return aufsteigend ? vergleich : -vergleich;
|
|
});
|
|
|
|
zeilen.forEach((row) => tbody.appendChild(row));
|
|
}
|
|
|
|
function sortWert(row, index) {
|
|
const zelle = row.children[index];
|
|
return zelle && "sortWert" in zelle.dataset ? zelle.dataset.sortWert : undefined;
|
|
}
|
|
|
|
function zellText(row, index) {
|
|
const zelle = row.children[index];
|
|
return zelle ? zelle.textContent.trim() : "";
|
|
}
|
|
|
|
function setzeVersteckt(row, grund, versteckt) {
|
|
row.dataset["versteckt" + grund] = versteckt ? "1" : "0";
|
|
const gruende = Object.keys(row.dataset).filter((k) => k.startsWith("versteckt"));
|
|
row.hidden = gruende.some((k) => row.dataset[k] === "1");
|
|
}
|
|
|
|
function filtereTabelle(table, suchtext) {
|
|
const nadel = suchtext.trim().toLowerCase();
|
|
table.querySelectorAll("tbody tr").forEach((row) => {
|
|
setzeVersteckt(row, "Suche", nadel.length > 0 && !row.textContent.toLowerCase().includes(nadel));
|
|
});
|
|
}
|
|
|
|
function baueSpaltenFilter(table, th, spaltenIndex) {
|
|
const werte = new Set();
|
|
table.querySelectorAll("tbody tr").forEach((row) => {
|
|
werte.add(zellText(row, spaltenIndex) || "—");
|
|
});
|
|
|
|
// Nutzer-Feedback 11.09.2026 (zweimal jetzt, siehe auch den Bugfix
|
|
// vom Vortag zum gleichen Thema): das Icon soll IMMER erscheinen,
|
|
// unabhängig davon, wie viele unterschiedliche Werte aktuell
|
|
// vorkommen - nicht nur ab 2 Werten. Konsistenz/Verlässlichkeit
|
|
// schlägt hier den (kleinen) Nutzen, ein ungenutztes Icon bei nur
|
|
// einem Wert auszublenden.
|
|
if (werte.size === 0) return;
|
|
|
|
const knopf = document.createElement("button");
|
|
knopf.type = "button";
|
|
knopf.className = "spalten-filter";
|
|
knopf.setAttribute("aria-label", "Nach " + th.textContent.trim() + " filtern");
|
|
// Nutzer-Feedback 11.09.2026: ein Dreieck (▾) sieht der Sortier-
|
|
// Funktion zu ähnlich, leicht verwechselbar. Eigenes kleines
|
|
// Trichter-Symbol statt Emoji (rendert konsistent über Schriftarten/
|
|
// Betriebssysteme hinweg, keine externe Icon-Bibliothek nötig).
|
|
knopf.innerHTML =
|
|
'<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
|
|
'<path d="M1 2h14l-5.5 6.5v5L6.5 15v-6.5z"/></svg>';
|
|
|
|
const menu = document.createElement("div");
|
|
menu.className = "spalten-filter-menu";
|
|
menu.hidden = true;
|
|
|
|
const werkzeuge = document.createElement("div");
|
|
werkzeuge.className = "spalten-filter-werkzeuge";
|
|
const werkzeug = (text, aktion) => {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.textContent = text;
|
|
b.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
aktion();
|
|
});
|
|
werkzeuge.appendChild(b);
|
|
};
|
|
if ("sort" in th.dataset) {
|
|
werkzeug("↑ Aufsteigend", () => sortiereTabelle(table, th, spaltenIndex, "asc"));
|
|
werkzeug("↓ Absteigend", () => sortiereTabelle(table, th, spaltenIndex, "desc"));
|
|
}
|
|
const alleSetzen = (an) => {
|
|
menu.querySelectorAll("label input").forEach((c) => (c.checked = an));
|
|
wendeSpaltenFilterAn(table, spaltenIndex, menu, th);
|
|
};
|
|
werkzeug("Alle an", () => alleSetzen(true));
|
|
werkzeug("Alle aus", () => alleSetzen(false));
|
|
menu.appendChild(werkzeuge);
|
|
|
|
Array.from(werte).sort((a, b) => a.localeCompare(b, "de")).forEach((wert) => {
|
|
const label = document.createElement("label");
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.checked = true;
|
|
checkbox.value = wert;
|
|
checkbox.addEventListener("change", () => wendeSpaltenFilterAn(table, spaltenIndex, menu, th));
|
|
label.appendChild(checkbox);
|
|
label.append(" " + wert);
|
|
menu.appendChild(label);
|
|
});
|
|
|
|
knopf.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const warOffen = !menu.hidden;
|
|
document.querySelectorAll(".spalten-filter-menu").forEach((m) => (m.hidden = true));
|
|
menu.hidden = warOffen;
|
|
if (!menu.hidden) richteFilterMenuAus(menu);
|
|
});
|
|
|
|
th.appendChild(knopf);
|
|
th.appendChild(menu);
|
|
}
|
|
|
|
// Fest am Fenster, linksbündig unter dem Trichter; nie über den
|
|
// Fensterrand hinaus, bei Platzmangel nach oben.
|
|
function richteFilterMenuAus(menu) {
|
|
const knopf = menu.parentElement.querySelector(".spalten-filter");
|
|
if (!knopf) return;
|
|
const k = knopf.getBoundingClientRect();
|
|
const breite = menu.offsetWidth;
|
|
const hoehe = menu.offsetHeight;
|
|
|
|
let links = Math.min(k.left, window.innerWidth - breite - FILTER_RAND);
|
|
links = Math.max(FILTER_RAND, links);
|
|
|
|
const platzUnten = window.innerHeight - k.bottom - FILTER_RAND;
|
|
const platzOben = k.top - FILTER_RAND;
|
|
let oben = k.bottom;
|
|
if (hoehe > platzUnten && platzOben > platzUnten) oben = k.top - hoehe;
|
|
oben = Math.max(FILTER_RAND, Math.min(oben, window.innerHeight - hoehe - FILTER_RAND));
|
|
|
|
menu.style.left = `${links}px`;
|
|
menu.style.top = `${oben}px`;
|
|
}
|
|
|
|
function wendeSpaltenFilterAn(table, spaltenIndex, menu, th) {
|
|
const alle = menu.querySelectorAll("label input");
|
|
const aktiv = new Set(
|
|
Array.from(alle).filter((c) => c.checked).map((c) => c.value)
|
|
);
|
|
table.querySelectorAll("tbody tr").forEach((row) => {
|
|
const zelle = zellText(row, spaltenIndex) || "—";
|
|
setzeVersteckt(row, "Filter" + spaltenIndex, !aktiv.has(zelle));
|
|
});
|
|
|
|
// Nutzer-Wunsch 11.09.2026: die Titelzeile "sanft farbig" markieren,
|
|
// solange der Filter tatsächlich einschränkt (nicht alle Werte
|
|
// angehakt) - das Trichter-Icon nutzt fill="currentColor", übernimmt
|
|
// die Akzentfarbe also automatisch mit.
|
|
th.classList.toggle("spalten-gefiltert", aktiv.size < alle.length);
|
|
}
|