Quelltext des aktuellen Standes
/**
* Wiederverwendbare Firmensuche aus dem Adressbuch (Autocomplete) mit Freitext
* und dem Angebot, eine unbekannte Firma gleich anzulegen.
*
* Aktiviert sich automatisch für jedes <input data-company-search="<endpoint>">.
* Der Endpoint muss ein JSON-Array mit Objekten der Form
* { id, name, customer_number?, address?, city? } liefern
* (z. B. /api/contacts/companies/search).
*
* Konfiguration per data-Attribut am Input:
* data-company-search Endpoint-URL (Pflicht; ?q=&limit= werden angehängt)
* data-cs-min Mindestzeichen vor der Suche (Default 2)
* data-cs-fill-id Element-ID, in die die Firmen-ID geschrieben wird
* data-cs-empty Wert für dieses Feld ohne Auswahl (Default "", Bestellungen: "0")
* data-cs-badge Element-ID der Plakette „aus dem Adressbuch". Sie wird bei einer
* Auswahl eingeblendet; ein darin liegender .csx-selected-clear-Knopf
* hebt die Auswahl auf.
* data-cs-clear Verhalten dieses Knopfes: "link" (Default) löst nur die Verknüpfung
* und lässt den Text als Freitext stehen; "input" leert zusätzlich das
* Suchfeld – richtig für reine Suchfelder.
* data-cs-create "1" = Der Vorschlag „‚…' anlegen" erscheint, sobald der eingetippte
* Name nicht als Firma existiert. Nur setzen, wenn die Person das
* Adressbuch pflegen darf – das Recht wird serverseitig erneut geprüft.
* data-cs-create-url Ziel des Anlegens (Default /contacts/companies)
*
* Zusätzlich werden auf dem Input zwei CustomEvents ausgelöst (bubbles):
* 'company-search:select' (detail = Firma) bei Auswahl – auch nach dem Anlegen
* 'company-search:clear' wenn eine Auswahl durch manuelle Eingabe entfällt
*
* Das Anlege-Fenster liefert die Vorlage `partials/_company_quick_create.php`; es wird
* einmal je Seite eingebunden und von allen Feldern gemeinsam genutzt.
*/
(function () {
'use strict';
var ACTIVE = 'csx-item--active';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
/** Das gemeinsame Anlege-Fenster der Seite, sofern eingebunden. */
function modal() {
return document.getElementById('csxCreateModal');
}
/**
* Öffnet das Anlege-Fenster mit vorbelegtem Namen.
*
* @param {string} name Der eingetippte Name.
* @param {string} url Ziel des Anlegens.
* @param {Function} onDone Erhält die angelegte Firma { id, name }.
*/
function openCreate(name, url, onDone) {
var box = modal();
if (!box) { return; }
var form = box.querySelector('form');
var fehler = box.querySelector('.csx-create-error');
form.reset();
fehler.style.display = 'none';
fehler.textContent = '';
form.querySelector('[name="name"]').value = name;
box.__csxDone = onDone;
box.__csxUrl = url;
box.style.display = 'flex';
var erstes = form.querySelector('[name="name"]');
if (erstes) { erstes.focus(); erstes.select(); }
}
function closeCreate() {
var box = modal();
if (!box) { return; }
box.style.display = 'none';
box.__csxDone = null;
}
/** Schickt das Anlege-Formular ab und meldet die neue Firma zurück. */
function submitCreate(ev) {
ev.preventDefault();
var box = modal();
var form = ev.target;
var fehler = box.querySelector('.csx-create-error');
var knopf = form.querySelector('[type="submit"]');
knopf.disabled = true;
fetch(box.__csxUrl || '/contacts/companies', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
body: new FormData(form)
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
.then(function (a) {
if (!a.ok || !a.data || !a.data.company) {
throw new Error((a.data && a.data.error) || 'Die Firma konnte nicht angelegt werden.');
}
var fertig = box.__csxDone;
closeCreate();
if (fertig) { fertig(a.data.company); }
})
.catch(function (e) {
fehler.textContent = e.message || 'Die Firma konnte nicht angelegt werden.';
fehler.style.display = '';
})
.finally(function () { knopf.disabled = false; });
}
function attach(input) {
if (!input || input.__csxAttached) { return; }
input.__csxAttached = true;
var endpoint = input.getAttribute('data-company-search') || '/api/contacts/companies/search';
var minChars = parseInt(input.getAttribute('data-cs-min') || '2', 10);
var fillId = input.getAttribute('data-cs-fill-id');
var leerWert = input.getAttribute('data-cs-empty') || '';
var badgeId = input.getAttribute('data-cs-badge');
var badge = badgeId ? document.getElementById(badgeId) : null;
var clearMode = input.getAttribute('data-cs-clear') || 'link';
var darfNeu = input.getAttribute('data-cs-create') === '1' && !!modal();
var neuUrl = input.getAttribute('data-cs-create-url') || '/contacts/companies';
var wrap = input.closest('.csx-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'csx-wrap';
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(input);
}
var dropId = 'csx-drop-' + Math.random().toString(36).slice(2, 9);
var drop = document.createElement('div');
drop.className = 'csx-dropdown';
drop.id = dropId;
drop.setAttribute('role', 'listbox');
wrap.appendChild(drop);
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-controls', dropId);
var timer = null, seq = 0, items = [], treffer = [], activeIdx = -1, open = false, hasSelection = false;
if (fillId) {
var preEl = document.getElementById(fillId);
if (preEl && preEl.value && preEl.value !== '0') { hasSelection = true; }
}
function showBadge(on) {
if (badge) { badge.style.display = on ? '' : 'none'; }
}
showBadge(hasSelection);
/**
* Hebt die Verknüpfung zum Adressbuch auf. Der Text bleibt stehen, solange das
* Feld selbst gespeichert wird – so bleibt die Eingabe als Freitext nutzbar.
*/
function dropSelection() {
if (clearMode === 'input') { input.value = ''; }
if (fillId) {
var idEl = document.getElementById(fillId);
if (idEl) { idEl.value = leerWert; }
}
hasSelection = false;
showBadge(false);
input.dispatchEvent(new CustomEvent('company-search:clear', { bubbles: true }));
}
if (badge) {
var clearBtn = badge.querySelector('.csx-selected-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
dropSelection();
close();
input.focus();
});
}
}
function close() {
open = false; activeIdx = -1; items = []; treffer = [];
drop.classList.remove('csx-dropdown--open');
drop.innerHTML = '';
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
function setActive(idx) {
if (!items.length) { return; }
if (idx < 0) { idx = items.length - 1; }
if (idx >= items.length) { idx = 0; }
items.forEach(function (el) {
el.classList.remove(ACTIVE); el.setAttribute('aria-selected', 'false');
});
activeIdx = idx;
var el = items[idx];
el.classList.add(ACTIVE);
el.setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', el.id);
el.scrollIntoView({ block: 'nearest' });
}
function selectCompany(c) {
if (!c) { return; }
input.value = c.name || '';
if (fillId) {
var el = document.getElementById(fillId);
if (el) { el.value = c.id || leerWert; }
}
hasSelection = true;
showBadge(true);
input.dispatchEvent(new CustomEvent('company-search:select', { detail: c, bubbles: true }));
close();
}
/** Trägt den Vorschlag „‚…' anlegen" als letzten Eintrag ein. */
function addCreateItem(q) {
var el = document.createElement('div');
el.className = 'csx-item csx-create';
el.id = dropId + '-opt-neu';
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', 'false');
el.innerHTML = '<i class="fas fa-plus" aria-hidden="true"></i> ' +
'<span class="csx-create-text">„' + esc(q) + '" als Firma anlegen</span>';
el.addEventListener('mousedown', function (ev) {
ev.preventDefault();
close();
openCreate(q, neuUrl, selectCompany);
});
el.addEventListener('mouseenter', function () { setActive(items.length - 1); });
drop.appendChild(el);
items.push(el);
}
function render(list, q) {
treffer = Array.isArray(list) ? list : [];
drop.innerHTML = '';
items = [];
treffer.forEach(function (c, i) {
var el = document.createElement('div');
el.className = 'csx-item';
el.id = dropId + '-opt-' + i;
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', 'false');
var sub = [c.customer_number, c.city].filter(Boolean).map(esc).join(' · ');
el.innerHTML = '<div class="csx-item-name">' + esc(c.name) + '</div>' +
(sub ? '<div class="csx-item-sub">' + sub + '</div>' : '');
el.addEventListener('mousedown', function (ev) { ev.preventDefault(); selectCompany(c); });
el.addEventListener('mouseenter', function () { setActive(i); });
drop.appendChild(el);
items.push(el);
});
// Der Vorschlag erscheint, solange keine Firma genau so heißt – auch neben
// Treffern, die nur zufällig denselben Wortanfang haben.
var genau = treffer.some(function (c) {
return String(c.name || '').toLowerCase() === q.toLowerCase();
});
if (darfNeu && !genau) {
addCreateItem(q);
} else if (!treffer.length) {
var none = document.createElement('div');
none.className = 'csx-empty';
none.textContent = 'Keine Firma gefunden – der Text wird so übernommen.';
drop.appendChild(none);
}
open = true;
drop.classList.add('csx-dropdown--open');
input.setAttribute('aria-expanded', 'true');
if (items.length) { setActive(0); }
}
function query(q) {
var mine = ++seq;
var url = endpoint + (endpoint.indexOf('?') >= 0 ? '&' : '?') +
'q=' + encodeURIComponent(q) + '&limit=20';
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) { if (mine === seq) { render(list, q); } })
.catch(function () { if (mine === seq) { close(); } });
}
input.addEventListener('input', function () {
clearTimeout(timer);
if (hasSelection) {
hasSelection = false;
showBadge(false);
if (fillId) { var fe = document.getElementById(fillId); if (fe) { fe.value = leerWert; } }
input.dispatchEvent(new CustomEvent('company-search:clear', { bubbles: true }));
}
var q = input.value.trim();
if (q.length < minChars) { close(); return; }
timer = setTimeout(function () { query(q); }, 220);
});
input.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' && input.value.trim().length >= minChars) {
e.preventDefault();
query(input.value.trim());
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(activeIdx + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(activeIdx - 1); }
else if (e.key === 'Enter') {
if (activeIdx < 0 || !items[activeIdx]) { return; }
e.preventDefault();
if (items[activeIdx].classList.contains('csx-create')) {
var q = input.value.trim();
close();
openCreate(q, neuUrl, selectCompany);
} else {
selectCompany(treffer[activeIdx]);
}
} else if (e.key === 'Escape') {
// Nur das Dropdown schließen – nicht das umgebende Fenster.
e.preventDefault(); e.stopPropagation(); close();
} else if (e.key === 'Tab') {
close();
}
});
document.addEventListener('click', function (e) {
if (!wrap.contains(e.target)) { close(); }
});
}
function init(root) {
var scope = root || document;
Array.prototype.forEach.call(scope.querySelectorAll('input[data-company-search]'), attach);
var box = modal();
if (box && !box.__csxReady) {
box.__csxReady = true;
box.querySelector('form').addEventListener('submit', submitCreate);
Array.prototype.forEach.call(box.querySelectorAll('[data-csx-close]'), function (el) {
el.addEventListener('click', closeCreate);
});
box.addEventListener('mousedown', function (e) { if (e.target === box) { closeCreate(); } });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && box.style.display !== 'none') { closeCreate(); }
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { init(document); });
} else {
init(document);
}
window.CompanySearch = { attach: attach, init: init, openCreate: openCreate };
})();