OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

app.js

46 KB
/**
 * IT-Dokumentation - Haupt-JavaScript
 * Enterprise-Grade, Security-Focused
 */

'use strict';

// Namespace
window.OpenNIT = window.OpenNIT || {};

/**
 * Schützt Modals vor versehentlichem Schließen bei Textauswahl.
 *
 * Viele Modal-Backdrops schließen per `if (e.target === overlay) close()`.
 * Beginnt eine Mausauswahl IM Modal (z. B. Text markieren/kopieren) und endet
 * außerhalb auf dem Overlay, feuert der Browser einen `click`, dessen target
 * der Overlay ist (gemeinsamer Vorfahre von mousedown- und mouseup-Ziel) – das
 * Modal schließt sich ungewollt.
 *
 * Diese global (Capture-Phase) registrierte Wache unterdrückt genau solche
 * Klicks: nur wenn der Klick auf einem Backdrop mit „modal"/„overlay" in der
 * Klasse endet UND die Mausaktion innerhalb dieses Backdrops begann. Normale
 * Klicks (gleiches mousedown-/click-Ziel) sowie echte Backdrop-Klicks
 * (mousedown + mouseup auf dem Backdrop selbst) bleiben unberührt.
 */
(function () {
    var downEl = null;
    document.addEventListener('mousedown', function (e) { downEl = e.target; }, true);
    document.addEventListener('click', function (e) {
        var start = downEl;
        downEl = null;
        if (!start || start === e.target) {
            return; // einfacher Klick – nichts zu tun
        }
        var t = e.target;
        if (!t || t.nodeType !== 1 || typeof t.contains !== 'function') {
            return;
        }
        var cls = typeof t.className === 'string'
            ? t.className
            : (t.getAttribute ? (t.getAttribute('class') || '') : '');
        // Backdrop-artiges Ziel + Auswahl begann darin → versehentlicher Close.
        if (/(modal|overlay)/i.test(cls) && t.contains(start)) {
            e.stopPropagation();
        }
    }, true);
}());

/**
 * Initialisierung
 */
document.addEventListener('DOMContentLoaded', function() {
    OpenNIT.init();
});

/**
 * Hauptinitialisierung
 */
OpenNIT.init = function() {
    this.initDropdowns();
    this.initFlashMessages();
    this.initConfirmDialogs();
    this.initMobileSidebar();
    this.initKeyboardShortcuts();
    this.initSearchAutocomplete();
    this.initScrollRestore();
    this.initBusyForms();
};

/**
 * Formulare, deren Antwort auf sich warten lässt, zeigen das.
 *
 * Ein Vorgang, der ein Fremdsystem befragt, braucht leicht zehn Sekunden und
 * mehr. Ohne Rückmeldung wirkt der Klick, als sei er ins Leere gegangen – und
 * ein zweiter Klick bricht die laufende Anfrage ab und beginnt von vorn, womit
 * die Antwort nie ankommt. Ein Formular mit `data-busy="Text …"` sperrt
 * deshalb beim Absenden seine Schaltflächen und zeigt diesen Text.
 *
 * Gesperrt wird erst im nächsten Zyklus: Eine vorher deaktivierte Schaltfläche
 * schickt ihren eigenen Namen nicht mit, und davon hängen Formulare mit
 * mehreren Absendern ab.
 */
OpenNIT.initBusyForms = function() {
    document.querySelectorAll('form[data-busy]').forEach(function(form) {
        form.addEventListener('submit', function() {
            var text    = form.getAttribute('data-busy') || 'Bitte warten …';
            var knoepfe = form.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]');
            if (!knoepfe.length || form.dataset.busyAktiv === '1') {
                return;
            }
            form.dataset.busyAktiv = '1';
            form.setAttribute('aria-busy', 'true');

            setTimeout(function() {
                knoepfe.forEach(function(btn, i) {
                    if (i === 0 && btn.tagName === 'BUTTON') {
                        btn.textContent = '';
                        var dreher = document.createElement('span');
                        dreher.className = 'spinner';
                        dreher.setAttribute('aria-hidden', 'true');
                        btn.appendChild(dreher);
                        btn.appendChild(document.createTextNode(' ' + text));
                    }
                    btn.disabled = true;
                });
            }, 0);
        });
    });
};

/**
 * Bewahrt die vertikale Scrollposition langer Listen über die
 * Full-Page-Navigation hinweg: Wer in einer Tabelle nach unten scrollt, einen
 * Eintrag öffnet und zurückkehrt, landet wieder an derselben Stelle statt am
 * Listenanfang.
 *
 * Nur auf Seiten mit einer Standard-Datentabelle aktiv (`.data-table` /
 * `.table-wrapper`), damit Detail- und Formularseiten unberührt bleiben. Der
 * Speicherschlüssel schließt Pfad und Query ein, sodass verschiedene Filter-
 * und Seitenzustände jeweils ihre eigene Position behalten.
 */
OpenNIT.initScrollRestore = function() {
    if (!document.querySelector('.data-table, .table-wrapper')) {
        return;
    }

    var key = 'onit:list-scroll:' + location.pathname + location.search;

    function save() {
        try {
            sessionStorage.setItem(key, String(window.scrollY || window.pageYOffset || 0));
        } catch (e) { /* sessionStorage gesperrt – dann ohne Wiederherstellung */ }
    }
    function restore() {
        try {
            var raw = sessionStorage.getItem(key);
            if (raw === null) return;
            var y = parseInt(raw, 10);
            if (y > 0) window.scrollTo(0, y);
        } catch (e) { /* ignorieren */ }
    }

    // Eigene Wiederherstellung übernimmt die Position; die Browser-Automatik
    // wird abgeschaltet, damit es keine konkurrierenden Sprünge gibt.
    if ('scrollRestoration' in history) {
        history.scrollRestoration = 'manual';
    }

    // Position laufend (gedrosselt) sichern und zusätzlich verlässlich beim
    // Verlassen/Verstecken der Seite (pagehide bleibt bfcache-freundlich).
    var pending = false;
    window.addEventListener('scroll', function() {
        if (pending) return;
        pending = true;
        window.setTimeout(function() { pending = false; save(); }, 150);
    }, { passive: true });
    window.addEventListener('pagehide', save);

    // Beim Betreten wiederherstellen – auch bei Rückkehr aus dem bfcache.
    restore();
    window.addEventListener('pageshow', function(e) {
        if (e.persisted) restore();
    });
};

/**
 * Dropdown-Menus
 */
OpenNIT.initDropdowns = function() {
    const dropdowns = document.querySelectorAll('.dropdown');

    dropdowns.forEach(function(dropdown) {
        const toggle = dropdown.querySelector('.dropdown-toggle');
        const menu = dropdown.querySelector('.dropdown-menu');

        if (!toggle || !menu) return;

        toggle.addEventListener('click', function(e) {
            e.stopPropagation();
            const isOpen = dropdown.classList.contains('open');

            // Alle anderen schließen
            document.querySelectorAll('.dropdown.open').forEach(function(d) {
                d.classList.remove('open');
            });

            if (!isOpen) {
                dropdown.classList.add('open');
            }
        });
    });

    // User-Menu (Header)
    const userMenus = document.querySelectorAll('.user-menu');
    userMenus.forEach(function(userMenu) {
        const trigger = userMenu.querySelector('.user-menu-trigger');
        const dropdown = userMenu.querySelector('.user-menu-dropdown');

        if (!trigger || !dropdown) return;

        trigger.addEventListener('click', function(e) {
            e.stopPropagation();
            const isOpen = userMenu.classList.contains('open');

            // Alle anderen schließen
            document.querySelectorAll('.user-menu.open, .dropdown.open').forEach(function(d) {
                d.classList.remove('open');
            });

            if (!isOpen) {
                userMenu.classList.add('open');
            }
        });
    });

    // Klick außerhalb schließt Dropdown
    document.addEventListener('click', function() {
        document.querySelectorAll('.dropdown.open, .user-menu.open').forEach(function(d) {
            d.classList.remove('open');
        });
    });

    // Escape schließt Dropdown
    document.addEventListener('keydown', function(e) {
        if (e.key === 'Escape') {
            document.querySelectorAll('.dropdown.open, .user-menu.open').forEach(function(d) {
                d.classList.remove('open');
            });
        }
    });
};

/**
 * Flash-Nachrichten automatisch ausblenden
 */
OpenNIT.initFlashMessages = function() {
    const flashMessages = document.querySelectorAll('.flash-message');

    flashMessages.forEach(function(message) {
        // Nach 5 Sekunden ausblenden
        setTimeout(function() {
            message.style.opacity = '0';
            message.style.transform = 'translateY(-10px)';
            setTimeout(function() {
                message.remove();
            }, 300);
        }, 5000);

        // Klick zum sofortigen Schließen
        message.addEventListener('click', function() {
            this.remove();
        });

        message.style.cursor = 'pointer';
        message.title = window.onitText('Klicken zum Schließen');
    });
};

/**
 * Bestätigungsdialoge für gefährliche Aktionen
 */
OpenNIT.initConfirmDialogs = function() {
    document.querySelectorAll('[data-confirm]').forEach(function(element) {
        element.addEventListener('click', function(e) {
            const message = this.dataset.confirm || 'Sind Sie sicher?';
            if (!confirm(message)) {
                e.preventDefault();
            }
        });
    });
};

/**
 * Mobile Sidebar Toggle
 */
OpenNIT.initMobileSidebar = function() {
    const toggle = document.querySelector('.sidebar-toggle');
    const sidebar = document.querySelector('.app-sidebar');

    if (!toggle || !sidebar) return;

    toggle.addEventListener('click', function() {
        sidebar.classList.toggle('open');
    });

    // Klick außerhalb schließt Sidebar
    document.addEventListener('click', function(e) {
        if (sidebar.classList.contains('open') &&
            !sidebar.contains(e.target) &&
            !toggle.contains(e.target)) {
            sidebar.classList.remove('open');
        }
    });
};

/**
 * Tastaturkürzel
 */
OpenNIT.initKeyboardShortcuts = function() {
    document.addEventListener('keydown', function(e) {
        // Nur wenn kein Input fokussiert
        if (document.activeElement.tagName === 'INPUT' ||
            document.activeElement.tagName === 'TEXTAREA' ||
            document.activeElement.isContentEditable) {
            return;
        }

        // Ctrl/Cmd + K: Suche fokussieren
        if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
            e.preventDefault();
            const searchInput = document.querySelector('.header-search input');
            if (searchInput) {
                searchInput.focus();
            }
        }

        // ?: Hilfe anzeigen
        if (e.key === '?' && !e.shiftKey) {
            OpenNIT.showKeyboardHelp();
        }
    });
};

/**
 * Tastaturhilfe anzeigen.
 *
 * Globale Shortcuts sind hier statisch verdrahtet; seitenspezifische
 * Shortcuts können sich per `OpenNIT.pageShortcuts = [{key, action, group}]`
 * registrieren und werden dann unter einer eigenen Gruppen-Überschrift
 * angezeigt (Gruppen-Default 'Diese Seite').
 */
OpenNIT.showKeyboardHelp = function() {
    const globalShortcuts = [
        { key: 'Ctrl + K', action: window.onitText('Suche öffnen') },
        { key: '?',        action: window.onitText('Diese Hilfe anzeigen') },
        { key: 'Escape',   action: window.onitText('Dialoge/Menüs schließen') }
    ];

    function renderList(list) {
        let dl = '<dl>';
        list.forEach(function(s) {
            dl += '<dt><kbd>' + s.key + '</kbd></dt><dd>' + s.action + '</dd>';
        });
        return dl + '</dl>';
    }

    let html = '<div class="keyboard-help"><h3>' + window.onitText('Tastaturkürzel') + '</h3>';
    html += '<h4 style="margin:0.5rem 0 0.25rem;font-size:0.85rem;color:#6b7280;">' + window.onitText('Global') + '</h4>';
    html += renderList(globalShortcuts);

    const pageShortcuts = (window.OpenNIT && Array.isArray(window.OpenNIT.pageShortcuts)) ? window.OpenNIT.pageShortcuts : [];
    if (pageShortcuts.length) {
        const groups = {};
        pageShortcuts.forEach(function(s) {
            const g = s.group || window.onitText('Diese Seite');
            (groups[g] = groups[g] || []).push(s);
        });
        Object.keys(groups).forEach(function(g) {
            html += '<h4 style="margin:0.8rem 0 0.25rem;font-size:0.85rem;color:#6b7280;">' + g + '</h4>';
            html += renderList(groups[g]);
        });
    }

    html += '<button class="btn btn-secondary" onclick="this.parentElement.parentElement.remove()">' + window.onitText('Schließen') + '</button></div>';

    const overlay = document.createElement('div');
    overlay.className = 'modal';
    overlay.innerHTML = '<div class="modal-backdrop" onclick="this.parentElement.remove()"></div><div class="modal-dialog">' + html + '</div>';
    document.body.appendChild(overlay);
};

/**
 * Such-Autocomplete
 */
OpenNIT.initSearchAutocomplete = function() {
    const searchInput = document.querySelector('.header-search input');
    if (!searchInput) return;

    let debounceTimer;
    let resultsContainer;

    // Container erstellen
    resultsContainer = document.createElement('div');
    resultsContainer.className = 'search-autocomplete';
    searchInput.parentElement.appendChild(resultsContainer);

    searchInput.addEventListener('input', function() {
        const query = this.value.trim();

        clearTimeout(debounceTimer);

        if (query.length < 2) {
            resultsContainer.innerHTML = '';
            resultsContainer.style.display = 'none';
            return;
        }

        debounceTimer = setTimeout(function() {
            OpenNIT.fetchSearchResults(query, resultsContainer);
        }, 300);
    });

    // Blur schließt Ergebnisse
    searchInput.addEventListener('blur', function() {
        setTimeout(function() {
            resultsContainer.style.display = 'none';
        }, 200);
    });

    // Enter führt vollständige Suche aus
    searchInput.addEventListener('keydown', function(e) {
        if (e.key === 'Enter') {
            window.location.href = '/search?q=' + encodeURIComponent(this.value);
        }
    });
};

/**
 * Suchergebnisse laden
 */
OpenNIT.fetchSearchResults = function(query, container) {
    fetch('/search/autocomplete?q=' + encodeURIComponent(query), {
        headers: {
            'Accept': 'application/json'
        }
    })
    .then(function(response) {
        return response.json();
    })
    .then(function(data) {
        if (data.results && data.results.length > 0) {
            let html = '<ul class="autocomplete-results">';
            data.results.forEach(function(result) {
                html += '<li><a href="' + OpenNIT.escapeHtml(result.url) + '">';
                html += '<span class="result-type">' + OpenNIT.escapeHtml(result.type) + '</span>';
                html += '<span class="result-title">' + OpenNIT.escapeHtml(result.title) + '</span>';
                html += '</a></li>';
            });
            html += '</ul>';
            html += '<a href="/search?q=' + encodeURIComponent(query) + '" class="autocomplete-all">Alle Ergebnisse anzeigen</a>';
            container.innerHTML = html;
            container.style.display = 'block';
        } else {
            container.innerHTML = '<div class="autocomplete-empty">Keine Ergebnisse</div>';
            container.style.display = 'block';
        }
    })
    .catch(function() {
        container.style.display = 'none';
    });
};

/**
 * HTML escapen
 */
OpenNIT.escapeHtml = function(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
};

/**
 * CSRF-Token holen
 */
OpenNIT.getCsrfToken = function() {
    const input = document.querySelector('input[name="_csrf"]');
    return input ? input.value : '';
};

/**
 * Fetch mit CSRF
 */
OpenNIT.fetch = function(url, options) {
    options = options || {};
    options.headers = options.headers || {};

    if (options.method && options.method !== 'GET') {
        options.headers['X-CSRF-Token'] = OpenNIT.getCsrfToken();
    }

    return fetch(url, options);
};

/**
 * Format relative Zeit
 */
OpenNIT.relativeTime = function(dateString) {
    const date = new Date(dateString);
    const now = new Date();
    const diffMs = now - date;
    const diffSec = Math.floor(diffMs / 1000);
    const diffMin = Math.floor(diffSec / 60);
    const diffHour = Math.floor(diffMin / 60);
    const diffDay = Math.floor(diffHour / 24);

    if (diffSec < 60) return 'Gerade eben';
    if (diffMin < 60) return 'vor ' + diffMin + ' Min.';
    if (diffHour < 24) return 'vor ' + diffHour + ' Std.';
    if (diffDay < 7) return 'vor ' + diffDay + ' Tag' + (diffDay > 1 ? 'en' : '');

    return date.toLocaleDateString('de-DE');
};

/**
 * Format Dateigröße
 */
OpenNIT.formatFileSize = function(bytes) {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

/**
 * Notification anzeigen
 */
OpenNIT.notify = function(message, type) {
    type = type || 'info';

    const notification = document.createElement('div');
    notification.className = 'flash-message flash-' + type;
    notification.textContent = message;
    notification.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; cursor: pointer;';

    notification.addEventListener('click', function() {
        this.remove();
    });

    document.body.appendChild(notification);

    setTimeout(function() {
        notification.style.opacity = '0';
        setTimeout(function() {
            notification.remove();
        }, 300);
    }, 5000);
};

/**
 * Debounce Helper
 */
OpenNIT.debounce = function(func, wait) {
    let timeout;
    return function() {
        const context = this;
        const args = arguments;
        clearTimeout(timeout);
        timeout = setTimeout(function() {
            func.apply(context, args);
        }, wait);
    };
};

/**
 * Copy to Clipboard
 */
OpenNIT.copyToClipboard = function(text) {
    if (navigator.clipboard) {
        navigator.clipboard.writeText(text).then(function() {
            OpenNIT.notify('In Zwischenablage kopiert', 'success');
        });
    } else {
        // Fallback
        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.select();
        document.execCommand('copy');
        document.body.removeChild(textarea);
        OpenNIT.notify('In Zwischenablage kopiert', 'success');
    }
};

/**
 * SearchableSelect – durchsuchbares Dropdown mit Hierarchie-Darstellung.
 * Dropdown wird an <body> gehängt (position:fixed) → kein overflow:hidden-Clipping.
 * Aktivierung: <select data-searchable>
 *
 * Mit `data-allow-custom` wird das Feld zur Kombination aus Auswahl und
 * Freitext: Passt die Eingabe zu keinem Eintrag, lässt sie sich als eigener
 * Wert übernehmen. Der Wert liegt dann als zusätzliche Option im Select und
 * wird wie jeder andere abgeschickt – gedacht für Felder, die einen bekannten
 * Eintrag bevorzugen, aber nicht darauf begrenzt sind.
 */
(function () {
    function SearchableSelect(nativeSelect) {
        this.native = nativeSelect;
        this.allowCustom = nativeSelect.hasAttribute('data-allow-custom');
        // Firmenfelder bieten an, einen unbekannten Namen gleich im Adressbuch
        // anzulegen; das Fenster dazu liefert company-search.js.
        this.createCompany    = nativeSelect.hasAttribute('data-create-company');
        this.createCompanyUrl = nativeSelect.getAttribute('data-create-company-url') || '/contacts/companies';
        this._buildUI();
        this._bindEvents();
        this.sync();
    }

    SearchableSelect.prototype._buildUI = function () {
        var wrap = document.createElement('div');
        wrap.className = 'ss-wrap';
        // Kompakte Feldgröße (Filterleisten/Toolbars) übernehmen, damit der
        // Trigger zur Höhe der form-control-sm-Nachbarfelder passt.
        if (this.native.classList.contains('form-control-sm')) { wrap.className += ' ss-wrap--sm'; }
        this.native.parentNode.insertBefore(wrap, this.native);
        wrap.appendChild(this.native);
        // Nativer Select bleibt im DOM (hält Wert, wird abgeschickt), aber
        // visuell versteckt. Bewusst NICHT display:none, damit die Browser-
        // Validierung eines `required`-Selects den Fokus setzen kann und das
        // Abschicken nicht ohne Meldung blockiert (siehe .ss-native in app.css).
        this.native.classList.add('ss-native');

        var trigger = document.createElement('button');
        trigger.type = 'button';
        trigger.className = 'ss-trigger';
        trigger.setAttribute('aria-haspopup', 'listbox');
        trigger.setAttribute('aria-expanded', 'false');
        var label = document.createElement('span');
        label.className = 'ss-trigger-label';
        trigger.appendChild(label);
        wrap.appendChild(trigger);
        this.trigger = trigger;
        this.triggerLabel = label;

        // Dropdown wird an <body> gehängt, position:fixed verhindert overflow-Clipping
        var dropdown = document.createElement('div');
        dropdown.className = 'ss-dropdown';
        dropdown.setAttribute('role', 'listbox');
        // Kennzeichnet die Liste als Ebene eines Dialogs: Steht das Feld in
        // einem Dialog, bleibt der Fokus im Suchfeld, obwohl die Liste am
        // <body> hängt (siehe dialog.js).
        dropdown.setAttribute('data-dialog-layer', '');
        var searchWrap = document.createElement('div');
        searchWrap.className = 'ss-search-wrap';
        var searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.className = 'ss-search';
        searchInput.placeholder = 'Suchen…';
        searchInput.setAttribute('autocomplete', 'off');
        searchWrap.appendChild(searchInput);
        dropdown.appendChild(searchWrap);
        this.searchInput = searchInput;

        var list = document.createElement('div');
        list.className = 'ss-list';
        dropdown.appendChild(list);
        this.list = list;

        document.body.appendChild(dropdown); // <-- an body, nicht an wrap
        this.dropdown = dropdown;
        this.wrap = wrap;
        this.native._ss = this;
    };

    SearchableSelect.prototype._positionDropdown = function () {
        var rect = this.trigger.getBoundingClientRect();
        var dd = this.dropdown;
        var viewH = window.innerHeight;
        var ddH = 300; // geschätzte max-Höhe

        dd.style.position = 'fixed';
        dd.style.left = rect.left + 'px';
        dd.style.width = rect.width + 'px';
        dd.style.minWidth = Math.max(rect.width, 180) + 'px';
        dd.style.zIndex = '99999';

        // Nach unten oder oben öffnen je nach Platz
        if (rect.bottom + ddH > viewH && rect.top > ddH) {
            dd.style.top = '';
            dd.style.bottom = (viewH - rect.top + 4) + 'px';
        } else {
            dd.style.top = (rect.bottom + 4) + 'px';
            dd.style.bottom = '';
        }
    };

    SearchableSelect.prototype._buildList = function (filter) {
        var self = this;
        this.list.innerHTML = '';
        this.items = [];           // DOM-Nodes der gerenderten Items (für Tastatur-Navigation)
        this.itemOpts = [];        // Zugehörige <option>-Referenzen, gleicher Index wie items
        filter = (filter || '').toLowerCase();
        var opts = this.native.options;
        var found = 0;

        for (var i = 0; i < opts.length; i++) {
            var opt = opts[i];
            if (opt.disabled) continue;
            var text = opt.textContent.trim();
            if (filter && text.toLowerCase().indexOf(filter) === -1) continue;

            var depth = parseInt(opt.getAttribute('data-depth') || '0', 10);
            var item = document.createElement('div');
            item.className = 'ss-item' +
                (opt.selected ? ' ss-selected' : '') +
                (opt.value === '' ? ' ss-empty-opt' : '');
            item.setAttribute('data-value', opt.value);
            item.setAttribute('data-depth', depth);
            item.setAttribute('role', 'option');

            if (depth > 0 && !filter) {
                var arrow = document.createElement('span');
                arrow.className = 'ss-indent';
                arrow.setAttribute('aria-hidden', 'true');
                arrow.textContent = '↳';
                item.appendChild(arrow);
            }
            var span = document.createElement('span');
            span.textContent = text;
            item.appendChild(span);

            (function (o, el, idx) {
                el.addEventListener('mousedown', function (e) {
                    e.preventDefault();
                    self._selectOpt(o);
                    self._close();
                });
                // Maus-Hover synchron mit Tastatur-Highlight, damit Klick
                // und Pfeiltasten sich nicht in den Quere kommen.
                el.addEventListener('mousemove', function () { self._setActive(idx); });
            })(opt, item, this.items.length);

            this.items.push(item);
            this.itemOpts.push(opt);
            self.list.appendChild(item);
            found++;
        }

        // Freitext anbieten, solange die Eingabe zu keinem Eintrag exakt passt.
        var raw = (this.searchInput && this.searchInput.value || '').trim();
        if (this.allowCustom && raw !== '' && !this._hasExactOption(raw)) {
            var customItem = document.createElement('div');
            customItem.className = 'ss-item ss-item-custom';
            customItem.setAttribute('role', 'option');
            var label = document.createElement('span');
            label.textContent = '„' + raw + '" übernehmen';
            customItem.appendChild(label);

            (function (value, el) {
                el.addEventListener('mousedown', function (e) {
                    e.preventDefault();
                    self._selectOpt(self._customOption(value));
                    self._close();
                });
                el.addEventListener('mousemove', function () {
                    self._setActive(self.items.indexOf(el));
                });
            })(raw, customItem);

            this.list.insertBefore(customItem, this.list.firstChild);
            this.items.unshift(customItem);
            this.itemOpts.unshift(this._customPlaceholder(raw));
            found++;
        }

        // Anlegen anbieten, solange keine Firma genau so heißt.
        if (this.createCompany && raw !== '' && !this._hasExactOption(raw) && window.CompanySearch) {
            var neuItem = document.createElement('div');
            neuItem.className = 'ss-item ss-item-create';
            neuItem.setAttribute('role', 'option');
            var neuLabel = document.createElement('span');
            neuLabel.textContent = window.onitText
                ? window.onitText('„{name}" als Firma anlegen', { name: raw })
                : '„' + raw + '" als Firma anlegen';
            neuItem.appendChild(neuLabel);

            (function (value, el) {
                el.addEventListener('mousedown', function (e) {
                    e.preventDefault();
                    self._openCompanyCreate(value);
                });
                el.addEventListener('mousemove', function () {
                    self._setActive(self.items.indexOf(el));
                });
            })(raw, neuItem);

            this.list.insertBefore(neuItem, this.list.firstChild);
            this.items.unshift(neuItem);
            this.itemOpts.unshift(this._createPlaceholder(raw));
            found++;
        }

        if (found === 0) {
            var noRes = document.createElement('div');
            noRes.className = 'ss-no-results';
            noRes.textContent = 'Keine Ergebnisse';
            self.list.appendChild(noRes);
            this.activeIndex = -1;
        } else {
            // Beim Filter den ersten Treffer voraktivieren, sonst das aktuell
            // selektierte Item; fällt das nicht in die Filtermenge, der erste.
            var preferred = -1;
            if (filter) {
                preferred = 0;
            } else {
                for (var j = 0; j < this.itemOpts.length; j++) {
                    if (this.itemOpts[j].selected) { preferred = j; break; }
                }
                if (preferred < 0) preferred = 0;
            }
            this._setActive(preferred);
        }
    };

    /** Gibt es bereits eine Option mit exakt diesem Text? */
    /**
     * Passt die Eingabe genau zu einem Eintrag? Zusätze hinter „ · " (etwa die
     * Kundennummer hinter dem Firmennamen) bleiben dabei außer Betracht, damit
     * der eingetippte Name auch dann als vorhanden gilt.
     */
    SearchableSelect.prototype._hasExactOption = function (text) {
        var needle = text.toLowerCase();
        for (var i = 0; i < this.native.options.length; i++) {
            var voll = this.native.options[i].textContent.trim().toLowerCase();
            if (voll === needle || voll.split(' · ')[0].trim() === needle) { return true; }
        }
        return false;
    };

    /**
     * Option für einen Freitext-Wert – vorhandene wird wiederverwendet, damit
     * mehrfaches Eintippen keine Duplikate im Select hinterlässt.
     */
    SearchableSelect.prototype._customOption = function (value) {
        for (var i = 0; i < this.native.options.length; i++) {
            if (this.native.options[i].getAttribute('data-custom') === '1') {
                this.native.options[i].value = value;
                this.native.options[i].textContent = value;
                return this.native.options[i];
            }
        }
        var opt = document.createElement('option');
        opt.value = value;
        opt.textContent = value;
        opt.setAttribute('data-custom', '1');
        this.native.appendChild(opt);
        return opt;
    };

    /**
     * Platzhalter für die Tastatur-Navigation: Enter auf dem Freitext-Eintrag
     * legt die Option erst beim Übernehmen an.
     */
    SearchableSelect.prototype._customPlaceholder = function (value) {
        var self = this;
        return { __customValue: value, get value() { return value; },
                 textContent: value, selected: false,
                 resolve: function () { return self._customOption(value); } };
    };

    /**
     * Platzhalter für die Tastatur-Navigation auf dem Anlegen-Eintrag: Enter
     * öffnet das Fenster, statt einen Wert zu übernehmen.
     */
    SearchableSelect.prototype._createPlaceholder = function (value) {
        var self = this;
        return { value: value, textContent: value, selected: false,
                 openCreate: function () { self._openCompanyCreate(value); } };
    };

    /** Öffnet das Anlege-Fenster und übernimmt die neue Firma als Auswahl. */
    SearchableSelect.prototype._openCompanyCreate = function (value) {
        var self = this;
        self._close();
        window.CompanySearch.openCreate(value, this.createCompanyUrl, function (firma) {
            self._selectOpt(self._companyOption(firma));
        });
    };

    /** Option der angelegten Firma – vorhandene wird wiederverwendet. */
    SearchableSelect.prototype._companyOption = function (firma) {
        for (var i = 0; i < this.native.options.length; i++) {
            if (this.native.options[i].value === String(firma.id)) { return this.native.options[i]; }
        }
        var opt = document.createElement('option');
        opt.value = String(firma.id);
        opt.textContent = firma.name;
        this.native.appendChild(opt);
        return opt;
    };

    SearchableSelect.prototype._setActive = function (idx) {
        if (!this.items || !this.items.length) { this.activeIndex = -1; return; }
        if (idx < 0) idx = 0;
        if (idx >= this.items.length) idx = this.items.length - 1;
        for (var i = 0; i < this.items.length; i++) {
            this.items[i].classList.toggle('ss-active', i === idx);
        }
        this.activeIndex = idx;
        // scrollIntoView mit 'nearest' verhindert, dass jeder Tastendruck die
        // Liste komplett umsortiert; nur wenn das Item wirklich aus dem
        // sichtbaren Bereich rausgleitet, wird gescrollt.
        this.items[idx].scrollIntoView({ block: 'nearest' });
    };

    SearchableSelect.prototype._commitActive = function () {
        if (this.activeIndex < 0 || !this.itemOpts || !this.itemOpts[this.activeIndex]) return false;
        var opt = this.itemOpts[this.activeIndex];
        if (opt && typeof opt.openCreate === 'function') { opt.openCreate(); return true; }
        if (opt && typeof opt.resolve === 'function') { opt = opt.resolve(); }
        this._selectOpt(opt);
        this._close();
        return true;
    };

    SearchableSelect.prototype.sync = function () {
        var sel = this.native;
        var idx = sel.selectedIndex;
        if (idx >= 0 && sel.options[idx] && sel.options[idx].value !== '') {
            this.triggerLabel.textContent = sel.options[idx].textContent.trim();
            this.trigger.classList.remove('ss-placeholder');
        } else {
            this.triggerLabel.textContent = sel.options[0] ? sel.options[0].textContent.trim() : window.onitText('— auswählen —');
            this.trigger.classList.add('ss-placeholder');
        }
    };

    SearchableSelect.prototype._selectOpt = function (opt) {
        for (var i = 0; i < this.native.options.length; i++) {
            this.native.options[i].selected = false;
        }
        opt.selected = true;
        this.native.dispatchEvent(new Event('change', { bubbles: true }));
        this.sync();
    };

    SearchableSelect.prototype._open = function () {
        // Alle anderen schließen
        document.querySelectorAll('.ss-dropdown.open').forEach(function (d) { d.classList.remove('open'); });
        this._positionDropdown();
        this.dropdown.classList.add('open');
        this.trigger.setAttribute('aria-expanded', 'true');
        this.searchInput.value = '';
        this._buildList('');
        this.searchInput.focus();
    };

    SearchableSelect.prototype._close = function () {
        this.dropdown.classList.remove('open');
        this.trigger.setAttribute('aria-expanded', 'false');
    };

    SearchableSelect.prototype._bindEvents = function () {
        var self = this;
        this.trigger.addEventListener('click', function (e) {
            e.stopPropagation();
            self.dropdown.classList.contains('open') ? self._close() : self._open();
        });
        this.searchInput.addEventListener('input', function () { self._buildList(this.value); });
        // Tastatur-Navigation im Suchfeld: ArrowDown/Up bewegt die Markierung
        // durch die Liste, Enter wählt aktuell markiertes Item, Tab wählt es
        // ebenfalls und lässt den Standard-Fokuswechsel zum nächsten Feld zu.
        // Home/End springen zum ersten/letzten Treffer.
        this.searchInput.addEventListener('keydown', function (e) {
            // Escape gilt der Liste, nicht dem Dialog darunter: erst schließt
            // die Auswahl, ein zweites Escape schließt den Dialog.
            if (e.key === 'Escape') { e.stopPropagation(); self._close(); self.trigger.focus(); return; }
            if (!self.items || !self.items.length) return;
            switch (e.key) {
                case 'ArrowDown':
                    e.preventDefault();
                    self._setActive(self.activeIndex < 0 ? 0 : self.activeIndex + 1);
                    break;
                case 'ArrowUp':
                    e.preventDefault();
                    self._setActive(self.activeIndex <= 0 ? 0 : self.activeIndex - 1);
                    break;
                case 'Home':
                    e.preventDefault();
                    self._setActive(0);
                    break;
                case 'End':
                    e.preventDefault();
                    self._setActive(self.items.length - 1);
                    break;
                case 'Enter':
                    e.preventDefault();
                    self._commitActive();
                    break;
                case 'Tab':
                    // Aktuelle Markierung übernehmen und Dropdown schließen.
                    // Fokus zurück auf den Trigger setzen — sonst sitzt der
                    // Fokus auf einem display:none-Element (das Suchfeld in
                    // der jetzt verborgenen Dropdown), und der native Tab/
                    // Shift+Tab-Sprung würde im body landen. preventDefault
                    // wäre kontraproduktiv: die Default-Aktion bewegt den
                    // Fokus vom Trigger zum nächsten (oder vorigen, bei
                    // Shift+Tab) Formularfeld.
                    if (self._commitActive()) {
                        self.trigger.focus();
                    }
                    break;
            }
        });
        // Scrollen → Position aktualisieren
        window.addEventListener('scroll', function () { if (self.dropdown.classList.contains('open')) self._positionDropdown(); }, true);
        window.addEventListener('resize', function () { if (self.dropdown.classList.contains('open')) self._positionDropdown(); });
        document.addEventListener('click', function () { self._close(); });
        // Klick im Dropdown selbst nicht schließen
        this.dropdown.addEventListener('click', function (e) { e.stopPropagation(); });
        this.wrap.addEventListener('click', function (e) { e.stopPropagation(); });
        this.native.addEventListener('change', function () { self.sync(); });
        // Abhängige Felder bauen ihre Optionen zur Laufzeit neu (etwa Ordner
        // zum gewählten Tresor) und setzen die Auswahl dabei direkt am Select.
        // Die Anzeige folgt diesem Umbau, auch ohne change-Ereignis.
        if (window.MutationObserver) {
            new MutationObserver(function () { self.sync(); })
                .observe(this.native, { childList: true, subtree: true });
        }
    };

    /**
     * Entscheidet, ob ein <select> zum durchsuchbaren Dropdown aufgewertet wird.
     * Frontend-Standard: ALLE Selects werden aufgewertet – so sehen die Dropdowns
     * modulübergreifend identisch aus. Ausnahmen: Mehrfachauswahl (`multiple`),
     * bereits initialisierte und ausdrücklich abgewählte (`data-no-search`).
     * Im Admin-Bereich (`body.admin-body`) gilt weiterhin Opt-in via
     * `data-searchable` (dortige Formulare sind dicht gepackt/spezieller).
     * @param {HTMLSelectElement} sel
     * @returns {boolean}
     */
    function shouldEnhanceSelect(sel) {
        // Nicht aufwerten: bereits aufgewertet, Mehrfachauswahl, deaktiviert,
        // ausdrücklich abgewählt (data-no-search) sowie Selects, die bereits von
        // einer eigenen Picker-Implementierung eines Moduls betreut werden
        // (data-ss – z. B. Inventar/Leihgeräte), sonst entstünde ein doppeltes
        // Auswahlfeld.
        if (sel._ss || sel.multiple || sel.disabled
            || sel.hasAttribute('data-no-search') || sel.hasAttribute('data-ss')) { return false; }
        if (sel.hasAttribute('data-searchable')) { return true; }
        return !(document.body && document.body.classList.contains('admin-body'));
    }

    /**
     * Wertet passende <select> im angegebenen Bereich zu durchsuchbaren Dropdowns
     * auf. Idempotent – für dynamisch eingefügte Formularzeilen wiederholt aufrufbar.
     * @param {ParentNode} [root=document] Wurzel, in der gesucht wird.
     */
    function initSearchableSelects(root) {
        (root || document).querySelectorAll('select').forEach(function (sel) {
            if (shouldEnhanceSelect(sel)) { new SearchableSelect(sel); }
        });
    }

    document.addEventListener('DOMContentLoaded', function () { initSearchableSelects(document); });

    window.ssSync = function (el) { if (el && el._ss) el._ss.sync(); };
    // Nachrüst-Hook für nachträglich eingefügte Selects (z. B. Wiederhol-Zeilen).
    window.ssInit = initSearchableSelects;
}());

/**
 * Merkt sich die Scrollposition der Navigationsleiste über den Seitenwechsel
 * hinweg. Die Navigation ist auf jeder Seite dieselbe Liste; ohne dieses
 * Merken beginnt sie nach jedem Klick wieder oben, obwohl der gewählte Punkt
 * weiter unten liegt.
 *
 * Gilt pro Tab (sessionStorage) und je Leiste (Frontend/Administration).
 */
(function () {
    'use strict';

    var LEISTEN = ['.main-sidebar', '.admin-sidebar'];

    function schluessel(selektor) {
        return 'opennit:nav-scroll:' + selektor;
    }

    function merken(el, selektor) {
        try {
            window.sessionStorage.setItem(schluessel(selektor), String(el.scrollTop));
        } catch (e) {
            // Privater Modus o. Ä.: Merken entfällt, Navigation bleibt nutzbar
        }
    }

    function herstellen(el, selektor) {
        try {
            var wert = parseInt(window.sessionStorage.getItem(schluessel(selektor)) || '0', 10);
            // Nur setzen, wenn die Leiste tatsächlich scrollbar ist – sonst
            // bliebe ein alter Wert aus einer längeren Seite hängen.
            if (wert > 0 && el.scrollHeight > el.clientHeight) {
                el.scrollTop = Math.min(wert, el.scrollHeight - el.clientHeight);
            }
        } catch (e) {
            // ignorieren
        }
    }

    function binden() {
        LEISTEN.forEach(function (selektor) {
            var el = document.querySelector(selektor);
            if (!el || el._navScrollGebunden) { return; }
            el._navScrollGebunden = true;

            herstellen(el, selektor);

            // Ab dem Klick auf einen Menüpunkt gilt die dort gemerkte Position
            // als endgültig: Der Browser rückt den angeklickten Link noch in den
            // Sichtbereich, und dieses Nachrücken soll die Position nicht ersetzen.
            var eingefroren = false;
            var timer = null;

            el.addEventListener('scroll', function () {
                if (eingefroren) { return; }
                clearTimeout(timer);
                timer = setTimeout(function () { merken(el, selektor); }, 120);
            }, { passive: true });

            el.addEventListener('click', function (e) {
                if (e.target.closest('a')) {
                    clearTimeout(timer);
                    merken(el, selektor);
                    eingefroren = true;
                }
            }, true);

            window.addEventListener('pagehide', function () {
                if (!eingefroren) { merken(el, selektor); }
            });
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', binden);
    } else {
        binden();
    }
}());

/**
 * Dialoge der Kopf- und Fußleiste (Über, Fehlermeldung, Lizenzen).
 *
 * Sie stehen im gemeinsamen Layout und erscheinen damit auch auf Seiten mit
 * strenger Content-Security-Policy, wo Ereignisattribute nicht mehr laufen.
 * Verdrahtet wird deshalb hier – app.js ist eine eigene Datei und von 'self'
 * gedeckt.
 *
 *   data-dialog-show="id"   Dialog einblenden (hidden entfernen)
 *   data-dialog-hide="id"   Dialog ausblenden (hidden setzen)
 *   data-dialog-keep        Klick nicht an den Hintergrund weiterreichen
 * Beides zusammen wechselt von einem Dialog zum anderen.
 */
(function () {
    'use strict';

    var SELEKTOR = '[data-dialog-show],[data-dialog-hide],[data-dialog-keep]';

    document.addEventListener('click', function (e) {
        var el = e.target.closest(SELEKTOR);
        if (!el) { return; }

        if (el.hasAttribute('data-dialog-keep')
            && !el.hasAttribute('data-dialog-show') && !el.hasAttribute('data-dialog-hide')) {
            e.stopPropagation();
            return;
        }

        var zu = document.getElementById(el.getAttribute('data-dialog-hide'));
        if (zu) { zu.setAttribute('hidden', ''); }

        var auf = document.getElementById(el.getAttribute('data-dialog-show'));
        if (auf) { auf.removeAttribute('hidden'); }
    });

    // Rückfrage an einem Knopf, dessen Formular sonst sofort abschickte.
    document.addEventListener('click', function (e) {
        var el = e.target.closest('[data-confirm-stop]');
        if (el && !window.confirm(el.getAttribute('data-confirm-stop'))) {
            e.preventDefault();
        }
    });
}());

/**
 * Geldbeträge im Browser so formatieren wie auf dem Server.
 *
 * Währung und Zahlenformat stehen als JSON im Meta-Feld `money-format`; fehlt
 * es, gilt dieselbe Vorgabe wie serverseitig (Euro, deutsches Format).
 */
(function () {
    'use strict';

    var VORGABE = { code: 'EUR', symbol: '€', position: 'nach', decimal: ',', thousand: '.' };

    function einstellung() {
        var meta = document.querySelector('meta[name="money-format"]');
        if (!meta) { return VORGABE; }
        try {
            var gelesen = JSON.parse(meta.getAttribute('content') || '{}');
            return {
                code:     gelesen.code     || VORGABE.code,
                symbol:   gelesen.symbol   || VORGABE.symbol,
                position: gelesen.position === 'vor' ? 'vor' : 'nach',
                decimal:  gelesen.decimal  || VORGABE.decimal,
                thousand: gelesen.thousand === undefined ? VORGABE.thousand : gelesen.thousand
            };
        } catch (e) {
            return VORGABE;
        }
    }

    window.OpenNIT = window.OpenNIT || {};
    window.OpenNIT.money = {
        /** Zahl im eingestellten Format, ohne Währungszeichen. */
        number: function (wert, stellen) {
            var e = einstellung();
            var teile = (isFinite(wert) ? Number(wert) : 0).toFixed(stellen === undefined ? 2 : stellen).split('.');
            teile[0] = teile[0].replace(/\B(?=(\d{3})+(?!\d))/g, e.thousand);
            return teile.join(e.decimal);
        },
        /** Betrag mit Währungszeichen, getrennt durch ein schmales Leerzeichen. */
        format: function (wert, stellen) {
            var e = einstellung();
            var zahl = window.OpenNIT.money.number(wert, stellen);
            return e.position === 'vor' ? e.symbol + ' ' + zahl : zahl + ' ' + e.symbol;
        },
        /** Nur das Währungszeichen, z. B. für Beschriftungen. */
        symbol: function () { return einstellung().symbol; }
    };
}());