OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

company-search.js

15 KB
/**
 * 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, '&amp;').replace(/</g, '&lt;')
            .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    }

    /** 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 };
})();