Quelltext des aktuellen Standes
/**
* 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; }
};
}());