fix: searchable client combobox in Nova instalação

Replaces the native list with a combobox (name/code/CNPJ search, accent- and
case-insensitive, keyboard + mouse, custom dropdown, hidden client_id = the
Gerenciador's real CLIENTE.ID). The backend resolves client_id against the
active GTI_GERENCIADOR mirror and rejects free text/unknown ids. Adds
Tenant.external_cnpj (migration 0025) mirrored via the internal endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 08:12:52 -03:00
co-authored by Claude Sonnet 5
parent 3ccef36c50
commit 9f9e06e9e9
9 changed files with 156 additions and 40 deletions
+74 -11
View File
@@ -11,12 +11,16 @@
{% else %}
<form method="post" action="/agents/new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Cliente
<input type="text" id="filtro-cliente" placeholder="Buscar cliente..." autocomplete="off" oninput="filtrarClientes(this.value)">
<select name="tenant_id" id="select-cliente" size="8" required>
{% for tenant in tenants %}<option value="{{ tenant.id }}">{{ tenant.name }}</option>{% endfor %}
</select>
</label>
<div class="combo-field">
<label for="cliente-busca">Cliente</label>
<div class="combo" id="combo-cliente">
<input type="text" id="cliente-busca" placeholder="Buscar por nome, código ou CNPJ..." autocomplete="off" role="combobox" aria-expanded="false" aria-controls="cliente-lista" aria-autocomplete="list">
<input type="hidden" name="client_id" id="cliente-id" value="">
<ul id="cliente-lista" class="combo-lista" role="listbox" hidden></ul>
</div>
<p class="error combo-erro" id="cliente-erro" role="alert" hidden>Selecione um cliente da lista.</p>
</div>
<script type="application/json" id="clientes-json">{{ clients_json|safe }}</script>
<label>Nome da instalação
<input name="display_name" placeholder="ex. Servidor Principal" maxlength="160" required>
</label>
@@ -30,13 +34,72 @@
</label>
<p><button type="submit">Criar instalação</button></p>
</form>
<style>
.combo { position: relative; }
.combo-lista { position: absolute; z-index: 20; left: 0; right: 0; top: 100%; margin: 4px 0 0; padding: 4px; list-style: none; max-height: 280px; overflow-y: auto; background: var(--cor-superficie); border: 1px solid var(--cor-borda); border-radius: 8px; box-shadow: var(--sombra-card); }
.combo-lista li { padding: 8px 10px; border-radius: 6px; cursor: pointer; display: flex; flex-direction: column; gap: 2px; }
.combo-lista li small { color: var(--cor-texto-secundario); }
.combo-lista li:hover, .combo-lista li.ativo { background: var(--cor-superficie-alt); }
.combo-lista li.selecionado { background: var(--cor-acento-suave); color: var(--cor-acento-texto); font-weight: 600; }
.combo-lista li.vazio { cursor: default; color: var(--cor-texto-secundario); }
.combo.invalido input[type=text] { border-color: var(--cor-perigo); }
.combo-field { margin-bottom: 12px; }
</style>
<script>
function filtrarClientes(termo) {
var alvo = termo.trim().toLowerCase();
document.querySelectorAll("#select-cliente option").forEach(function (opcao) {
opcao.hidden = alvo.length > 0 && opcao.textContent.toLowerCase().indexOf(alvo) === -1;
});
(function () {
var clientes = JSON.parse(document.getElementById("clientes-json").textContent);
var box = document.getElementById("cliente-busca"), hid = document.getElementById("cliente-id");
var lista = document.getElementById("cliente-lista"), erro = document.getElementById("cliente-erro");
var combo = document.getElementById("combo-cliente"), form = box.form;
var visiveis = [], ativo = -1;
function norm(t) { return (t || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); }
function cnpjFmt(d) {
if (!d) return "";
if (d.length === 14) return d.replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/, "$1.$2.$3/$4-$5");
if (d.length === 11) return d.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, "$1.$2.$3-$4");
return d;
}
function abrir(v) { lista.hidden = !v; box.setAttribute("aria-expanded", v ? "true" : "false"); }
function render() {
var q = norm(box.value), qd = q.replace(/\D/g, "");
visiveis = clientes.filter(function (c) {
if (!q) return true;
return norm(c.name).indexOf(q) !== -1 || norm(c.code).indexOf(q) !== -1 || (qd && (c.cnpj || "").indexOf(qd) !== -1);
}).slice(0, 100);
lista.innerHTML = "";
if (!visiveis.length) {
var vazio = document.createElement("li"); vazio.className = "vazio"; vazio.textContent = "Nenhum cliente ativo encontrado."; lista.appendChild(vazio);
}
visiveis.forEach(function (c, i) {
var li = document.createElement("li"); li.setAttribute("role", "option"); li.dataset.i = i;
if (String(c.id) === hid.value) li.classList.add("selecionado");
var nome = document.createElement("span"); nome.textContent = c.name; li.appendChild(nome);
if (c.cnpj) { var sm = document.createElement("small"); sm.textContent = cnpjFmt(c.cnpj); li.appendChild(sm); }
li.addEventListener("mousedown", function (e) { e.preventDefault(); escolher(c); });
lista.appendChild(li);
});
ativo = -1; abrir(true);
}
function destacar(n) {
var itens = lista.querySelectorAll("li[role=option]"); if (!itens.length) return;
ativo = (n + itens.length) % itens.length;
itens.forEach(function (li, i) { li.classList.toggle("ativo", i === ativo); });
itens[ativo].scrollIntoView({ block: "nearest" });
}
function escolher(c) { box.value = c.name; hid.value = c.id; erro.hidden = true; combo.classList.remove("invalido"); abrir(false); }
box.addEventListener("input", function () { hid.value = ""; render(); });
box.addEventListener("focus", render);
box.addEventListener("blur", function () { setTimeout(function () { abrir(false); }, 120); });
box.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); if (lista.hidden) render(); destacar(ativo + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); destacar(ativo - 1); }
else if (e.key === "Enter") { if (!lista.hidden && ativo >= 0 && visiveis[ativo]) { e.preventDefault(); escolher(visiveis[ativo]); } else if (!hid.value) { e.preventDefault(); } }
else if (e.key === "Escape") { abrir(false); }
});
form.addEventListener("submit", function (e) {
if (!hid.value) { e.preventDefault(); erro.hidden = false; combo.classList.add("invalido"); box.focus(); }
});
})();
</script>
{% endif %}
</section>