const restartHooks = []; let tableFetchController = null let root = document.querySelector(":root"); let copyToastTimeout; //#region Systemwide colors const COLORS = { BLUE: 'rgb(100, 149, 237)', GRAY: 'rgb(128, 128, 128)', GREEN: 'rgb(198, 244, 180)', YELLOW: 'rgb(255, 230, 153)', RED: 'rgb(248, 191, 173)', } //#endregion //#region Restart function addRestartHook(fn) { if (typeof fn === "function") restartHooks.push(fn); } function restart() { restartHooks.forEach(fn => fn()); location.reload(); } //#endregion //#region CSS // sets the css root variable name by value // function setCSSVariable(name, value) { root.style.setProperty(`--${name}`, value); } // gets the value of asked css variable name // function getCSSVariable(name) { return getComputedStyle(root).getPropertyValue(`--${name}`).trim(); } async function loadServerStyles(activeTheme = 'dark') { const res = await fetch('models/stylesheet.json'); const json = await res.json(); function toCssVars(obj, prefix = '', group = '') { let vars = []; for (const key in obj) { const value = obj[key]; const newPrefix = prefix ? `${prefix}-${key}` : key; if (Array.isArray(value)) { if (value.length === 3) { vars.push(`--${group}-${newPrefix}: rgb(${value.join(', ')});`); } else if (value.length === 4) { vars.push(`--${group}-${newPrefix}: rgba(${value.join(', ')});`); } } else if (typeof value === 'object' && value !== null) { vars.push(toCssVars(value, newPrefix, group)); } else if (value !== '') { vars.push(`--${group}-${newPrefix}: ${value};`); } } return vars.join('\n'); } let css = ''; // ========================= // 1. GLOBAL ROOT (times + base tokens) // ========================= if (json.times) { css += `:root {\n${toCssVars(json.times, '', 'times')}\n}\n`; } // ========================= // 2. THEME (selected variant) // ========================= const themeVars = json.theme?.[activeTheme] || {}; css += ` :root { ${toCssVars(themeVars, '', 'theme')} } `; // ========================= // 3. RESPONSIVE (media queries) // ========================= const responsive = json.responsive || {}; for (const breakpoint in responsive) { const bpVars = responsive[breakpoint]; // flatten vars const vars = toCssVars(bpVars, '', 'responsive'); let media = ''; if (breakpoint === 'mobile') { media = '@media (max-width: 600px)'; } else if (breakpoint === 'tablet') { media = '@media (min-width: 601px) and (max-width: 1024px)'; } else if (breakpoint === 'desktop') { media = '@media (min-width: 1025px)'; } else { continue; // unknown breakpoint skip } css += `${media} { :root { ${vars} } } `; } // ========================= // 4. INJECT STYLE TAG // ========================= let styleTag = document.getElementById('theme-styles'); if (!styleTag) { styleTag = document.createElement('style'); styleTag.id = 'theme-styles'; document.head.appendChild(styleTag); } // console.log(css) styleTag.textContent = css; const startmenuicon = document.getElementById('start-menu-icon'); if(!startmenuicon) { return true; } startmenuicon.src = `../images/radix_os_${activeTheme}_img.png`; return true; } function switchTheme(themeName) { setCookie('theme', themeName); // Theme direkt im Cookie speichern savedTheme = themeName; loadServerStyles(themeName); } function setFontFamily(fontFamily) { setCookie('fontfamily', fontFamily); // FontFamily direkt im Cookie speichern setCSSVariable('fontFamily', fontFamily) document.documentElement.style.fontFamily = fontFamily } function setFontSize(fontSize) { setCookie('fontsize', fontSize); // FontSize direkt im Cookie speichern setCSSVariable('fontSize', fontSize + 'px') document.documentElement.style.fontSize = fontSize + 'px' } //#endregion function copyToClipboard(text) { navigator.clipboard.writeText(text) .then(() => { showMessage('Kopiert!', `Text in Zwischenablage kopiert: ${text}`, levelId = -1, onclick = null, duration = 4000) }) .catch(err => { console.error('Copy fehlgeschlagen', err); }); } //#region Cookies try { // create or replace cookie function setCookie(name, value) { document.cookie=name + "=" + value + "; path=/; expires=" + (new Date(Number(new Date()) + (365 * 24 * 60 * 60 * 1000 * 10))).toGMTString(); } // read cookie value function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); } // remove cookie function deleteCookie(name) { if(getCookie(name) ) { document.cookie = `${name}= ;expires=Thu, 01 Jan 1970 00:00:01 GMT`; } } } catch( err ) { alert(err); } //#endregion let savedFontFamily = getCookie("fontfamily") || 'Arial'; let savedFontSize = getCookie("fontsize") || 14; let savedTheme = getCookie('theme') || 'dark'; //#region Format function formatHtml(text) { return text?.replace(/\r?\n/g, '
').replace(/\t/g, '    ') } function dateFormat(date, format = null) { if(date == null) return ""; format = (format == null ? "dd.mm.yyyy HH:MM:SS" : format); const finish_date = new Date(date); return format .replace('yyyy', finish_date.getFullYear()) .replace('yy', finish_date.getFullYear().toString().slice(-2)) .replace('mm', ("0" + (finish_date.getMonth() + 1)).slice(-2)) .replace('dd', ("0" + finish_date.getDate()).slice(-2)) .replace('HH', ("0" + finish_date.getHours()).slice(-2)) .replace('MM', ("0" + finish_date.getMinutes()).slice(-2)) .replace('SS', ("0" + finish_date.getSeconds()).slice(-2)); } function isNumeric(value) { return typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)); } function numberToCurrency(value, locale = 'de-DE', currency = 'EUR') { return new Intl.NumberFormat(locale, { style: 'currency', currency: currency }).format(value); } //#endregion //#region Custom style sheets async function loadCSSVars(path) { try { const res = await fetch(path); const vars = await res.json(); const root = document.documentElement; function setVars(obj, prefix = '') { for (const [key, value] of Object.entries(obj)) { if (typeof value === 'object') { setVars(value, `${prefix}${key}-`); } else { root.style.setProperty(`--${prefix}${key}`, value); } } } setVars(vars); } catch(err) { alert(err) } } //#endregion //#region Dropzones /** * HOWTO USE: * const dz = makeElementDropzone(tr, { * accept: ["image/png", "image/jpeg"], * maxFiles: 5, * maxFileSizeMB: 5, * onDrop: files => console.log(files) * }); * @param {*} container * @param {*} options * @returns */ function makeElementDropzone(el, { accept = [], maxFiles = Infinity, maxFileSizeMB = Infinity, onDrop }, allow = true) { el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add(allow ? 'drop-hover' : 'no-drop-hover'); }); el.addEventListener('dragleave', () => { el.classList.remove(allow ? 'drop-hover' : 'no-drop-hover'); }); el.addEventListener('drop', e => { e.preventDefault(); if(allow) { el.classList.remove('drop-hover'); const files = Array.from(e.dataTransfer.files); if (!files.length) return; const validFiles = []; for (const file of files) { if (accept.length && !accept.includes(file.type)) { alert(`${file.name}: Dateityp nicht erlaubt`); continue; } if (file.size > maxFileSizeMB * 1024 * 1024) { alert(`${file.name}: Datei zu groß`); continue; } validFiles.push(file); if (validFiles.length >= maxFiles) break; } if (validFiles.length && typeof onDrop === 'function') { onDrop(validFiles); } } }); } /** * HOWTO USE: * const dz = createDropzone(document.getElementById("dropzone"), { * accept: ["image/png", "image/jpeg"], * maxFiles: 5, * maxFileSizeMB: 5, * upload: file => fakeUpload(file), // optional * onChange: files => console.log(files) * }); * @param {*} container * @param {*} options * @returns */ function createDropzone(container, options = {}) { const { accept = [], maxFiles = Infinity, maxFileSizeMB = Infinity, upload = null, onChange = () => {} } = options; let files = []; // ---------- DOM ---------- const wrapper = document.createElement('div'); wrapper.className = 'dropzone-area'; const dropzone = document.createElement("div"); dropzone.className = "dropzone"; dropzone.textContent = "Dateien hier ablegen oder klicken"; const input = document.createElement("input"); input.type = "file"; input.hidden = true; input.multiple = true; input.accept = accept.join(","); const list = document.createElement("ul"); list.className = "file-list"; wrapper.append(dropzone, input, list); container.append(wrapper); // ---------- Helpers ---------- const isDuplicate = file => files.some(f => f.name === file.name && f.size === file.size); const isValid = file => { if (maxFiles !== undefined && files.length >= maxFiles) return "Maximale Dateianzahl erreicht"; if (maxFileSizeMB !== undefined && file.size > maxFileSizeMB * 1024 * 1024) return `Max. ${maxFileSizeMB}MB erlaubt`; if (accept !== undefined && accept.length && !accept.includes(file.type)) return "Dateityp nicht erlaubt"; if (isDuplicate(file)) return "Datei bereits vorhanden"; return null; }; const addFiles = selected => { Array.from(selected).forEach(file => { const error = isValid(file); if (!error) { files.push({ file, progress: 0 }); if (upload) startUpload(file); } else { alert(`${file.name}: ${error}`); } }); render(); onChange(getFiles()); }; const removeFile = index => { files.splice(index, 1); render(); onChange(getFiles()); }; // ---------- Upload ---------- function startUpload(file) { const entry = files.find(f => f.file === file); upload(file, p => { entry.progress = p; render(); }); } // ---------- Drag Reorder ---------- let dragIndex = null; function handleDragStart(i) { dragIndex = i; } function handleDrop(i) { const [moved] = files.splice(dragIndex, 1); files.splice(i, 0, moved); dragIndex = null; render(); onChange(getFiles()); } // ---------- Render ---------- function render() { list.innerHTML = ""; files.forEach((item, index) => { const li = document.createElement("li"); li.draggable = true; li.addEventListener("dragstart", () => handleDragStart(index)); li.addEventListener("dragover", e => e.preventDefault()); li.addEventListener("drop", () => handleDrop(index)); li.innerHTML = ` ${item.file.name} ${upload !== null ? `` : ''} ${(item.file.size / 1024 / 1000).toFixed(2)} MB `; li.querySelector("button").onclick = () => removeFile(index); list.appendChild(li); }); } // ---------- Events ---------- dropzone.onclick = () => input.click(); input.onchange = e => { addFiles(e.target.files); input.value = ""; }; dropzone.ondragover = e => { e.preventDefault(); dropzone.classList.add("active"); }; dropzone.ondragleave = () => dropzone.classList.remove("active"); dropzone.ondrop = e => { e.preventDefault(); dropzone.classList.remove("active"); addFiles(e.dataTransfer.files); }; // ---------- API ---------- const getFiles = () => files.map(f => f.file); return { getFiles, clear() { files = []; render(); onChange([]); } }; } //#endregion /* #region Multiselect textbox const multi = new MultiSelectTextbox({ container: document.getElementById('users'), source: [ { id: 1, name: "Max" }, { id: 2, name: "Anna" } ], keyFn: u => u.id, valueFn: u => u.name }); // API multi.add({ id: 3, name: "Tom" }); multi.removeByKey(1); multi.set([ { id: 1, name: "Max" }, { id: 2, name: "Anna" } ]); console.log(multi.getValues()); // Event document.getElementById('users') .addEventListener('change', e => console.log(e.detail)); */ class MultiSelectTextbox { constructor(options) { this.container = options.container; this.source = options.source ?? []; this.allowNew = options.allowNew ?? true; this.keyFn = options.keyFn ?? (item => item.key); this.valueFn = options.valueFn ?? (item => item.value); this.selected = options.selected ? [...options.selected] : []; this.init(); } /* ================= INIT ================= */ init() { this.inputWrapper = document.createElement('div'); this.inputWrapper.className = 'mst-wrAberapper'; this.container.appendChild(this.inputWrapper); this.input = document.createElement('input'); this.input.type = 'text'; this.input.className = 'mst-input'; this.inputWrapper.appendChild(this.input); this.chipsContainer = document.createElement('div'); this.chipsContainer.className = 'mst-chips-container'; this.inputWrapper.appendChild(this.chipsContainer); this.dropdown = document.createElement('div'); this.dropdown.className = 'mst-dropdown'; this.dropdown.style.display = 'none'; this.inputWrapper.appendChild(this.dropdown); this.input.addEventListener('input', () => this.updateDropdown()); this.input.addEventListener('keydown', e => this.handleKey(e)); this.input.addEventListener('dblclick', () => this.open()); document.addEventListener('click', e => { if (!this.container.contains(e.target)) this.close(); }); this.renderSelected(); } /* ================= RENDER ================= */ renderSelected() { this.chipsContainer.innerHTML = ''; this.selected.forEach(item => { const chip = document.createElement('span'); chip.className = 'mst-chip'; chip.textContent = this.valueFn(item); const removeBtn = document.createElement('span'); removeBtn.className = 'mst-chip-remove'; removeBtn.textContent = '×'; removeBtn.onclick = () => { this.removeByKey(this.keyFn(item)); }; chip.appendChild(removeBtn); this.chipsContainer.appendChild(chip); }); this.emitChange(); } /* ================= DROPDOWN ================= */ updateDropdown(query = this.input.value.toLowerCase()) { query = query.toLowerCase(); const filtered = this.source.filter(item => { const val = this.valueFn(item).toLowerCase(); return val.includes(query) && !this.selected.some(s => this.keyFn(s) === this.keyFn(item)); }); if (filtered.length === 0 && !this.allowNew && !query) { this.close(); return; } this.dropdown.innerHTML = ''; filtered.forEach(item => { const div = document.createElement('div'); div.className = 'mst-item'; div.textContent = this.valueFn(item); div.onclick = () => this.add(item); this.dropdown.appendChild(div); }); if (this.allowNew && query && !filtered.some(f => this.valueFn(f).toLowerCase() === query)) { const div = document.createElement('div'); div.className = 'mst-item new'; div.textContent = `Neuer Wert: "${this.input.value}"`; div.onclick = () => this.add({ key: query, value: this.input.value }); this.dropdown.appendChild(div); } this.dropdown.style.display = 'block'; } handleKey(e) { if (e.key === 'Enter') { e.preventDefault(); const first = this.dropdown.querySelector('.mst-item'); if (first) first.click(); } else if (e.key === 'Backspace' && !this.input.value) { this.selected.pop(); this.renderSelected(); } } open() { this.updateDropdown(''); this.input.focus(); } close() { this.dropdown.style.display = 'none'; } /* ================= PUBLIC API ================= */ getValues() { return this.selected.map(s => ({ key: this.keyFn(s), value: this.valueFn(s) })); } add(item) { const key = this.keyFn(item); if (this.selected.some(s => this.keyFn(s) === key)) return; this.selected.push(item); this.input.value = ''; this.renderSelected(); this.close(); } set(items) { this.selected = [...items]; this.renderSelected(); } removeByKey(key) { this.selected = this.selected.filter(s => this.keyFn(s) !== key); this.renderSelected(); } clear() { this.selected = []; this.renderSelected(); } setSource(items) { this.source = [...items]; this.updateDropdown(); } addSource(items) { if (!Array.isArray(items)) items = [items]; const existing = new Set(this.source.map(i => this.keyFn(i))); items.forEach(i => { if (!existing.has(this.keyFn(i))) this.source.push(i); }); this.updateDropdown(); } /* ================= EVENTS ================= */ emitChange() { this.container.dispatchEvent(new CustomEvent('change', { detail: this.getValues() })); } } //#endregion /** * e.g. client-side: * * @param {*} runtimeId * @param {*} fn */ function registerWindowCleanup(runtimeId, fn) { if (!windowCleanup.has(runtimeId)) { windowCleanup.set(runtimeId, []); } windowCleanup.get(runtimeId).push(fn); } //#region Reload Plugin Script function reloadPluginScript(src) { try { const old = document.querySelector(`script[src="${src}"]`); if (old) old.remove(); const script = document.createElement("script"); script.src = src + "?t=" + Date.now(); script.defer = true; document.body.appendChild(script); } catch(err) { alert(err); } } //#endregion //#region ToolTip // // Sehr langer Text // // // ℹ️ // const tooltip = document.createElement('div'); tooltip.className = 'global-tooltip'; document.body.appendChild(tooltip); let activeEl = null; let storedTitle = null; function positionTooltip(e) { const offset = 20; let x = e.clientX + offset; let y = e.clientY + offset; const rect = tooltip.getBoundingClientRect(); if (x + rect.width > window.innerWidth) { x = e.clientX - rect.width - offset; } if (y + rect.height > window.innerHeight) { y = e.clientY - rect.height + offset + 8; } if (x < 0) x = 0; if (y < 0) y = 0; tooltip.style.left = x + 'px'; tooltip.style.top = y + 'px'; } document.addEventListener('mouseover', e => { const el = e.target.closest('[data-tooltip]'); if (!el) return; const isTableCell = el.tagName === 'TD' || el.tagName === 'TH'; const mode = el.dataset.tooltipMode ?? (isTableCell ? 'ellipsis' : 'always'); if (mode === 'ellipsis' && el.scrollWidth <= el.clientWidth) return; activeEl = el; storedTitle = el.getAttribute('title'); if (storedTitle !== null) el.removeAttribute('title'); tooltip.innerHTML = el.dataset.tooltip; tooltip.classList.add('visible'); // 🔥 SOFORT richtig positionieren positionTooltip(e); }); document.addEventListener('mousemove', e => { if (!activeEl) return; positionTooltip(e); }); document.addEventListener('mouseout', e => { if (!activeEl) return; if (!activeEl.contains(e.relatedTarget)) { if (storedTitle !== null) activeEl.setAttribute('title', storedTitle); activeEl = null; storedTitle = null; tooltip.classList.remove('visible'); } }); //#endregion //#region Virtual table dataset /* const vt = virtualTable({ tableEl: tableCAT, data: [], groupKey: 'Initiale', rowHeight: 20, buffer: 5, filterConfig:{ exceptedColumns: ['Aktiv', 'ID', 'Anhänge'], columnModes:{ Aktiv: 'dropdown', Benutzername:'text', 'Abteilung 3':'dropdown', Genehmiger:'dropdown', Bedarfsmelder:'dropdown', Prioliste:'dropdown', Einkauf: 'dropdown', 'Admin':'dropdown', 'Mail Aktiv':'dropdown' }, checkboxFilter: { column: 'Aktiv', rules: [ { label: 'Aktiv: 🟢', test: v => v === true }, { label: '🔴', test: v => v === false } ] } }, customRender: (row, tr) => { createTd(tr, row['Aktiv'] ? '🟢' : '🔴', 'center'); createTd(tr, row['User_ID'], 'left'); createTd(tr, row['Benutzer'], 'left'); createTd(tr, row['BenutzerName'], 'left'); createTd(tr, row['Bedarfsmelder'], 'center'); createTd(tr, row['Genehmiger'], 'center'); createTd(tr, row['Abteilung 3'], 'center'); createTd(tr, row['Prioliste'], 'center'); createTd(tr, row['Einkauf'], 'center'); createTd(tr, row['Admin'], 'center'); createTd(tr, row['Mail Aktiv'], 'center'); /* createTd(tr, row['Gewerke'], 'center'); createTd(tr, row['Orgs'], 'center'); createTd(tr, row['Mail Benachrichtigungen'], 'left'); } }); */ function virtualTable({ tableEl, data = [], estimateHeight = 40, buffer = 6, groupKey = null, rowKey = "id", customRender = null, filterConfig = null, exceptedColumns = [] }) { let currentGroupKey = groupKey; let virtualData = []; let visibleRows = []; const rowHeights = []; const prefix = []; const groupCollapse = new Map(); const filterState = { selectedColumn: '', searchText: '', dropdownValue: '', dropdownCache: {}, checkbox: new Set() }; //-------------------------------------------- // Scroll Parent //-------------------------------------------- function getScrollParent(el) { let p = el.parentElement; while (p) { const s = getComputedStyle(p); if (/(auto|scroll)/.test(s.overflowY)) return p; p = p.parentElement; } return document.scrollingElement; } const wrapper = getScrollParent(tableEl); function syncFilterWidth(container){ function update(){ const rect = wrapper.getBoundingClientRect(); container.style.width = (rect.width - 11) + "px"; container.style.left = "0px"; } update(); wrapper.addEventListener('scroll', update, {passive:true}); } //-------------------------------------------- // Gruppen vorbereiten //-------------------------------------------- function prepareData() { virtualData = []; if (!currentGroupKey) { virtualData = data.map(r => ({...r, isGroupHeader:false})); } else { const groups = {}; data.forEach(r=>{ const key = r[currentGroupKey] ?? "Keine Gruppe"; (groups[key]??=[]).push(r); }); Object.keys(groups).forEach(key=>{ const gIndex = virtualData.length; const collapsed = groupCollapse.get(key) || false; virtualData.push({isGroupHeader:true, groupKey:key, collapsed}); groups[key].forEach(r=>{ virtualData.push({...r,isGroupHeader:false,groupIndex:gIndex}); }); }); } applyFilters(); rebuildPrefix(); } //-------------------------------------------- // Filter auf virtuelle Daten anwenden //-------------------------------------------- function applyFilters() { visibleRows = []; let visibleCount = 0; // alle vorherigen Filterzustände zurücksetzen virtualData.forEach(row => row._filteredOut = false); virtualData.forEach(row => { if (row.isGroupHeader) { visibleRows.push(row); return; } // Spaltenfilter if (filterState.selectedColumn) { const val = row[filterState.selectedColumn]; const mode = filterConfig.columnModes[filterState.selectedColumn] || 'text'; if (mode === 'text' && filterState.searchText) { if (!val?.toString().toLowerCase().includes(filterState.searchText)) { row._filteredOut = true; } } if (mode === 'dropdown' && filterState.dropdownValue) { if (val?.toString() !== filterState.dropdownValue) row._filteredOut = true; } } else if (filterState.searchText) { // globale Suche const match = Object.keys(row).some(k => { if(k==='isGroupHeader'||k==='groupIndex') return false; return row[k]?.toString().toLowerCase().includes(filterState.searchText); }); if (!match) row._filteredOut = true; } // Checkbox-Filter if (filterConfig?.checkboxFilter && filterState.checkbox.size > 0) { const val = row[filterConfig?.checkboxFilter.column]; const pass = [...filterState.checkbox].some(r => r.test(val)); if(!pass) row._filteredOut = true; } // Gruppierte Rows berücksichtigen const parent = virtualData[row.groupIndex]; if (parent?.collapsed || row._filteredOut) return; visibleRows.push(row); visibleCount++; }); // Live-Counter if(!filterConfig || filterConfig.hideCounter) return if(filterState.counterEl) filterState.counterEl.textContent = `${visibleCount} Treffer`; } //-------------------------------------------- // Visible rows prefix sums //-------------------------------------------- function rebuildPrefix(){ prefix.length = visibleRows.length+1; prefix[0]=0; for(let i=0;i>1; if(prefix[mid] { dots = (dots + 1) % 4; // 0..3 Punkte td.textContent = "Lade Daten" + ".".repeat(dots); }, 500); return; } // Wenn Daten vorhanden sind, Animation stoppen clearInterval(loadingInterval); const start = Math.max(0, findStart(scrollTop) - buffer); let end = start; while (end < visibleRows.length && prefix[end] - prefix[start] < viewport + buffer * estimateHeight) end++; tbody.innerHTML = ''; // top spacer const top = document.createElement("tr"); top.style.height = prefix[start] + "px"; tbody.appendChild(top); const frag = document.createDocumentFragment(); for (let i = start; i < end; i++) { const row = visibleRows[i]; const tr = document.createElement("tr"); tr.dataset.index = i; if (row.isGroupHeader) { tr.className = "grouprow"; const td = document.createElement("td"); td.colSpan = 100; const toggle = document.createElement("span"); toggle.textContent = row.collapsed ? '►' : '▼'; toggle.style.cursor = "var(--theme-cursor-pointer) -16 16 , pointer"; const toggleFn = () => { row.collapsed = !row.collapsed; groupCollapse.set(row.groupKey, row.collapsed); applyFilters(); rebuildPrefix(); render(); }; toggle.onclick = toggleFn; tr.ondblclick = toggleFn; td.append(toggle, " ", row.groupKey); tr.appendChild(td); } else { if (customRender) customRender(row, tr); else { Object.keys(row).forEach(k => { if (k === "isGroupHeader" || k === "groupIndex") return; const td = document.createElement("td"); td.textContent = row[k]; tr.appendChild(td); }); } } frag.appendChild(tr); } tbody.appendChild(frag); // bottom spacer const bottom = document.createElement("tr"); bottom.style.height = (prefix[visibleRows.length] - prefix[end]) + "px"; tbody.appendChild(bottom); measureRows(); } //-------------------------------------------- // echte Höhen messen //-------------------------------------------- function measureRows(){ const trs=tableEl.querySelectorAll("tbody tr[data-index]"); let changed=false; trs.forEach(tr=>{ const i=Number(tr.dataset.index); const h=tr.getBoundingClientRect().height; if(rowHeights[i]!==h){ rowHeights[i]=h; changed=true; } }); if(changed){ rebuildPrefix(); requestAnimationFrame(render); } } //-------------------------------------------- // Scroll & Resize //-------------------------------------------- wrapper.addEventListener("scroll",()=>requestAnimationFrame(render)); new ResizeObserver(()=>requestAnimationFrame(render)).observe(wrapper); // const wrapper = getScrollParent(tableEl); // Optional: auch Fenstergröße überwachen window.addEventListener('resize', () => { rebuildPrefix(); requestAnimationFrame(render); }); //-------------------------------------------- // Init Filter-UI //-------------------------------------------- function initFilterUI() { if(!filterConfig) return; const container = document.createElement('div'); container.className = 'table-filter-container'; container.style.position = 'sticky'; container.style.top = '0px'; container.style.zIndex = 20; tableEl.before(container); // Höhe messen → thead offset setzen requestAnimationFrame(()=>{ const h = container.getBoundingClientRect().height; tableEl.style.setProperty('--filter-height', h + 'px'); }); if(!filterConfig.hideCounter) { filterState.counterEl = document.createElement('div'); filterState.counterEl.className = 'live-counter'; container.appendChild(filterState.counterEl); } syncFilterWidth(container); const wrapperResizeObserver = new ResizeObserver(() => { // Filter-Header anpassen syncFilterWidth(container); // Tabelle neu rendern rebuildPrefix(); render(); }); wrapperResizeObserver.observe(wrapper); const wrap = document.createElement('div'); wrap.style="display:flex;gap:10px;align-items:center"; // Spaltenauswahl const colSelect = document.createElement('select'); const emptyOpt = document.createElement('option'); emptyOpt.value=''; emptyOpt.textContent='-- Alles --'; colSelect.appendChild(emptyOpt); tableEl.querySelectorAll('thead th').forEach(th=>{ if(!filterConfig?.exceptedColumns?.includes(th.textContent.trim())){ const opt = document.createElement('option'); opt.value=th.textContent.trim(); opt.textContent=th.textContent.trim(); colSelect.appendChild(opt); } }); wrap.appendChild(colSelect); // Textinput const input = document.createElement('input'); input.type='text'; wrap.appendChild(input); // Dropdown (hidden by default) const select = document.createElement('select'); select.style.display='none'; wrap.appendChild(select); // Event für Spaltenwechsel colSelect.addEventListener('change', () => { filterState.selectedColumn = colSelect.value; filterState.searchText = ''; filterState.dropdownValue = ''; input.value = ''; select.value = ''; if (!filterState.selectedColumn) { input.style.display = ''; select.style.display = 'none'; } else { const mode = filterConfig.columnModes[filterState.selectedColumn]; if (mode === 'text') { input.style.display = ''; select.style.display = 'none'; } else if (mode === 'dropdown') { input.style.display = 'none'; select.style.display = ''; populateDropdownForColumn(filterState.selectedColumn); // alle Werte aus virtualData } } // **Virtuelle Daten filtern** applyFilters(); render(); }); input.addEventListener('input',()=>{ filterState.searchText = input.value.toLowerCase(); applyFilters(); rebuildPrefix(); measureRows(); render(); }); select.addEventListener('change',()=>{ filterState.dropdownValue = select.value; applyFilters(); rebuildPrefix(); measureRows(); render(); }); container.appendChild(wrap); // Checkbox-Filter if (filterConfig?.checkboxFilter) { const cfgCheck = filterConfig?.checkboxFilter; const cbWrapper = document.createElement('div'); cbWrapper.style.display = "flex"; cbWrapper.style.gap = "12px"; cbWrapper.style.alignItems = "center"; cfgCheck.rules.forEach(rule => { const checkWrapper = document.createElement('label'); const checkInput = document.createElement('input'); const checkTrack = document.createElement('span'); const checkThumb = document.createElement('span'); // Deine Klassen checkWrapper.classList.add("cb", "cb-switch"); checkInput.type = 'checkbox'; checkTrack.classList.add('switch-track'); checkTrack.setAttribute("aria-hidden", 'true'); checkThumb.classList.add('switch-thumb'); checkThumb.setAttribute("aria-hidden", 'true'); // Verhalten checkInput.addEventListener('change', () => { if (checkInput.checked) filterState.checkbox.add(rule); else filterState.checkbox.delete(rule); applyFilters(); rebuildPrefix(); render(); }); // Aufbau (wichtig: input VOR track für CSS :checked + .switch-track) checkTrack.appendChild(checkThumb); checkWrapper.append(rule.label, checkInput, checkTrack); cbWrapper.appendChild(checkWrapper); }); container.appendChild(cbWrapper); } function populateDropdownForColumn(colName){ // Alle Werte aus der virtuellen Tabelle sammeln const values = new Set( virtualData .filter(r => !r.isGroupHeader) // nur echte Datenzeilen .map(r => r[colName]) ); // sortieren const sorted = [...values].sort((a,b)=> a.toString().localeCompare(b.toString())); // Options bauen select.innerHTML = '' + sorted.map(v=>``).join(''); } } //-------------------------------------------- // Init //-------------------------------------------- prepareData(); render(); initFilterUI(); return { addData(newData){ if (!newData) return; // JSON-Support if (typeof newData === "string") { try { newData = JSON.parse(newData); } catch { return; } } const incoming = Array.isArray(newData) ? newData : [newData]; // Upsert nach rowKey if (!Array.isArray(data) || data.length === 0) { data = incoming.slice(); } else { const indexMap = new Map(); data.forEach((row,i)=>{ const key = row?.[rowKey]; if(key != null) indexMap.set(key,i); }); incoming.forEach(row=>{ const key = row?.[rowKey]; if(key != null && indexMap.has(key)){ data[indexMap.get(key)] = row; // ersetzen } else { data.push(row); // anhängen } }); } //---------------------------------------- // Jetzt alle Filter korrekt anwenden //---------------------------------------- // virtualData neu vorbereiten prepareData(); // virtualData wird neu gebaut // Dropdown-Werte für ausgewählte Spalte aktualisieren if(filterState.selectedColumn && filterConfig?.columnModes[filterState.selectedColumn] === 'dropdown'){ populateDropdownForColumn(filterState.selectedColumn); } // _filteredOut zurücksetzen UND Filter neu anwenden applyFilters(); rebuildPrefix(); render(); // alles rendern }, removeData(target) { if (!target) return; //---------------------------------------- // Helper: normalize targets //---------------------------------------- let matcher; // Funktion → direkt verwenden if (typeof target === "function") { matcher = target; } // Array → mehrere IDs oder Objekte else if (Array.isArray(target)) { const keys = new Set( target.map(t => typeof t === "object" ? t[rowKey] : t) ); matcher = row => keys.has(row[rowKey]); } // Objekt → anhand rowKey else if (typeof target === "object") { matcher = row => row[rowKey] === target[rowKey]; } // Primitive → ID else { matcher = row => row[rowKey] === target; } //---------------------------------------- // Daten filtern //---------------------------------------- data = data.filter(row => !matcher(row)); //---------------------------------------- // Rebuild + Render //---------------------------------------- prepareData(); // Dropdown neu füllen (falls aktiv) if ( filterState.selectedColumn && filterConfig?.columnModes[filterState.selectedColumn] === 'dropdown' ) { populateDropdownForColumn(filterState.selectedColumn); } applyFilters(); rebuildPrefix(); render(); }, setGroupBy(newKey){ currentGroupKey = newKey; groupCollapse.clear(); // optional: alte Collapse-Zustände löschen prepareData(); render(); }, refresh(){ applyFilters(); render(); }, clearData() { data = [] }, source(newData) { data = []; this.addData(newData); this.refresh(); }, prepareData() { prepareData(); } }; } //#endregion //#region Table cells function createTd(tr, text, options = {}) { const { id = null, hidden = false, classes = [], styles = null, attributes = {}, onclick = null } = options; const td = document.createElement('td'); td.innerHTML = text ?? ''; if(id !== null) { td.id = id; } // Standard-Stile & Attribute td.hidden = hidden; classes.forEach(c => td.classList.add(c)); if (styles && typeof styles === 'object') { Object.entries(styles).forEach(([prop, value]) => { td.style[prop] = value; }); } // Data attributes if (attributes && typeof attributes === 'object') { Object.entries(attributes).forEach(([key, value]) => { td.setAttribute(key, value); }); } if(onclick !== undefined) { td.addEventListener('click', evt => { if( typeof onclick === 'function' ) onclick(); evt.preventDefault(); }); } tr.appendChild(td); requestAnimationFrame(() => { if (td.scrollWidth > td.clientWidth) { td.title = td.textContent.trim(); } }); return td; }; //#endregion //#region Tabs function createTab(tabSelector, name, options = {}) { const { id = null, hidden = false, classes = [], styles = null, attributes = {}, onclick = null } = options; const tabElement = document.createElement('div'); tabElement.className = 'tab'; tabElement.dataset.tab = name; tabElement.textContent = name; tabSelector.appendChild(tabElement); if(id !== null) { tabElement.id = id; } // Standard-Stile & Attribute tabElement.hidden = hidden; classes.forEach(c => tabElement.classList.add(c)); if (styles && typeof styles === 'object') { Object.entries(styles).forEach(([prop, value]) => { tabElement.style[prop] = value; }); } // Data attributes if (attributes && typeof attributes === 'object') { Object.entries(attributes).forEach(([key, value]) => { tabElement.setAttribute(key, value); }); } tabElement.addEventListener('click', async (evt) => { Array.from(tabSelector.querySelectorAll('.tab')).forEach(t => t.classList.remove('active')); tabElement.classList.add('active'); if(typeof onclick === 'function') { onclick(evt); } }); return tabElement; } //#endregion