Criação da tela de clientes.
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
502
app/templates/clientes.html
Normal file
502
app/templates/clientes.html
Normal file
@@ -0,0 +1,502 @@
|
||||
{% extends "index.html" %}
|
||||
{% block title %}Clientes{% endblock %}
|
||||
{% block content %}
|
||||
<h1>🧾 Clientes</h1>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0;">
|
||||
<input id="busca" type="text" placeholder="Pesquisar por nome/CNPJ…"
|
||||
style="padding:.6rem;border:1px solid #ddd;border-radius:10px;min-width:280px;">
|
||||
<button id="btnNovo" class="btn btn-primary" type="button">Novo Cliente</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:45%;">Cliente</th>
|
||||
<th style="width:25%;">CNPJ</th>
|
||||
<th style="width:15%;">Status</th>
|
||||
<th style="width:15%; text-align:right;">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-clientes">
|
||||
<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="modal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-backdrop" onclick="fecharModal()"></div>
|
||||
<div class="modal-card">
|
||||
<div id="status_bar" class="status-bar on" aria-hidden="true"></div>
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-titulo">Novo Cliente</h3>
|
||||
<button type="button" class="btn btn-secondary" onclick="fecharModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="form-modal" onsubmit="return salvarModal(event)">
|
||||
<input type="hidden" id="cli_id">
|
||||
|
||||
<div class="modal-body form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nome fantasia *</label>
|
||||
<input id="cli_nome" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>CNPJ</label>
|
||||
<input id="cli_cnpj"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="00.000.000/0000-00">
|
||||
</div>
|
||||
|
||||
<div class="form-group status-inline" id="grp_status">
|
||||
<div style="flex:1">
|
||||
<label>Status</label>
|
||||
<select id="cli_ativo" onchange="setStatusUI(this.value === 'true')">
|
||||
<option value="true">Ativo</option>
|
||||
<option value="false">Inativo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="fecharModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary">💾 Salvar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tbl-wrap{ background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb; }
|
||||
.tbl{ width:100%; border-collapse:separate; border-spacing:0; }
|
||||
.tbl thead th{
|
||||
background:#2563eb; color:#fff; padding:12px; text-align:left; font-weight:700;
|
||||
}
|
||||
.tbl thead th:first-child{ border-top-left-radius:12px; }
|
||||
.tbl thead th:last-child{ border-top-right-radius:12px; }
|
||||
.tbl tbody td{ padding:12px; border-top:1px solid #eef2f7; vertical-align:middle; }
|
||||
.tbl tbody tr:nth-child(even){ background:#f8fafc; }
|
||||
.muted{ color:#6b7280; text-align:center; padding:16px; }
|
||||
|
||||
.badge{ display:inline-block; padding:.2rem .6rem; border-radius:999px; font-weight:700; font-size:.78rem; color:#fff; }
|
||||
.on{ background:#16a34a; } .off{ background:#9ca3af; }
|
||||
|
||||
/* Modal */
|
||||
.hidden{ display:none; }
|
||||
.modal{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex; /* centraliza */
|
||||
align-items: center; /* <-- centraliza vertical */
|
||||
justify-content: center;
|
||||
padding: 6vh 16px; /* respiro e evita colar nas bordas */
|
||||
}
|
||||
.modal-backdrop{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15,23,42,.45);
|
||||
z-index: 0;
|
||||
}
|
||||
.modal-card{
|
||||
position: relative;
|
||||
z-index: 1; /* acima do backdrop */
|
||||
width: min(760px, 92vw); /* largura consistente */
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
overflow: hidden; /* barra acompanha cantos */
|
||||
box-shadow: 0 18px 40px rgba(0,0,0,.18);
|
||||
padding: 18px; /* respiro interno */
|
||||
}
|
||||
.modal-header{
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
margin-bottom: 12px; position: relative; z-index: 1;
|
||||
}
|
||||
.modal-header h3{ margin:0; font-size:1.4rem; }
|
||||
|
||||
.modal-body{
|
||||
margin-top: 6px; position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
.modal-footer{
|
||||
display:flex; justify-content:flex-end; gap:.6rem; margin-top:16px;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
.form-grid{ display:grid; grid-template-columns:1fr; gap:14px; }
|
||||
@media (min-width: 900px){ .form-grid{ grid-template-columns:1fr 1fr; } }
|
||||
.form-group label{ display:block; margin-bottom:6px; color:#374151; }
|
||||
.form-group input, .form-group select{
|
||||
width:100%; padding:.65rem .8rem; border:1px solid #e5e7eb;
|
||||
border-radius:12px; background:#fff;
|
||||
}
|
||||
.form-group input:focus, .form-group select:focus{
|
||||
outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.15);
|
||||
}
|
||||
|
||||
.status-inline{ display:flex; align-items:flex-end; gap:12px; }
|
||||
.badge{ display:inline-block; padding:.35rem .7rem; border-radius:999px; font-weight:700; color:#fff; }
|
||||
.badge.on{ background:#16a34a; } /* ativo */
|
||||
.badge.off{ background:#dc2626; } /* inativo */
|
||||
|
||||
.form-group label{ display:block; font-size:.9rem; color:#374151; margin-bottom:4px; }
|
||||
.form-group input, .form-group select{
|
||||
width:100%; padding:.55rem .7rem; border:1px solid #e5e7eb; border-radius:10px; background:#fff;
|
||||
}
|
||||
.hint{ font-size:.78rem; color:#6b7280; margin-top:4px; }
|
||||
|
||||
.btn {
|
||||
padding: .5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
.status-bar{
|
||||
position:absolute;
|
||||
top:0; left:0;
|
||||
width: 10px; /* espessura da barra */
|
||||
height:100%;
|
||||
background:#16a34a; /* default: ativo */
|
||||
pointer-events:none; /* não intercepta cliques */
|
||||
z-index: 0; /* fica por trás do conteúdo */
|
||||
}
|
||||
|
||||
/* Cores por estado */
|
||||
.status-bar.on { background:#16a34a; } /* ativo (verde) */
|
||||
.status-bar.off { background:#ef4444; } /* inativo (vermelho) */
|
||||
|
||||
.status-ativo {
|
||||
background-color: #16a34a; /* verde */
|
||||
}
|
||||
|
||||
.status-inativo {
|
||||
background-color: #ef4444; /* vermelho */
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
td.acoes { text-align: right; white-space: nowrap; }
|
||||
|
||||
.btn-icon{
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
background: #e5e7eb; /* cinza claro */
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.btn-icon:hover{ background:#d1d5db; }
|
||||
.btn-icon.danger{ background:#dc2626; color:#fff; }
|
||||
.btn-icon.danger:hover{ background:#b91c1c; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const onlyDigits = s => (s||'').replace(/\D/g,'');
|
||||
const elBody = document.getElementById('tbody-clientes');
|
||||
const elBusca = document.getElementById('busca');
|
||||
|
||||
function setStatusUI(isActive){
|
||||
const bar = document.getElementById('status_bar');
|
||||
if(!bar) return;
|
||||
bar.classList.toggle('on', isActive);
|
||||
bar.classList.toggle('off', !isActive);
|
||||
}
|
||||
|
||||
// monta uma linha da tabela
|
||||
function linha(c){
|
||||
return `
|
||||
<tr>
|
||||
<td>${c.nome_fantasia || '-'}</td>
|
||||
<td>${formatCNPJ(c.cnpj || '')}</td>
|
||||
<td>
|
||||
<span class="badge ${c.ativo ? 'on' : 'off'}">
|
||||
${c.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="acoes">
|
||||
<button class="btn-icon" title="Editar" aria-label="Editar"
|
||||
onclick='abrirModalEditar(${JSON.stringify(c)})'>
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-icon danger" title="Excluir" aria-label="Excluir"
|
||||
onclick="removerCliente('${c.id}')">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// renderiza a lista no tbody (com filtro da busca)
|
||||
function render(lista){
|
||||
const termo = (elBusca.value || '').toLowerCase();
|
||||
const filtrada = lista.filter(c =>
|
||||
(c.nome_fantasia || '').toLowerCase().includes(termo) ||
|
||||
(c.cnpj || '').includes(onlyDigits(termo))
|
||||
);
|
||||
elBody.innerHTML = filtrada.length
|
||||
? filtrada.map(linha).join('')
|
||||
: `<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>`;
|
||||
}
|
||||
|
||||
// carrega clientes do backend e renderiza
|
||||
async function carregar(busca = "") {
|
||||
const r = await fetch('/api/clientes');
|
||||
if (!r.ok){ console.error('Falha ao carregar clientes'); return; }
|
||||
const dados = await r.json();
|
||||
window.__clientes = dados; // guarda em memória para o filtro
|
||||
render(dados);
|
||||
}
|
||||
|
||||
// excluir cliente
|
||||
async function carregar(busca = "") {
|
||||
const r = await fetch(`/api/clientes?busca=${encodeURIComponent(busca)}`);
|
||||
if (!r.ok) { console.error('Falha ao carregar clientes'); return; }
|
||||
const dados = await r.json();
|
||||
window.__clientes = dados; // mantém em memória, se quiser
|
||||
render(dados);
|
||||
}
|
||||
|
||||
function abrirModalNovo(){
|
||||
const modal = document.getElementById('modal');
|
||||
const grpStatus = document.getElementById('grp_status');
|
||||
const inpId = document.getElementById('cli_id');
|
||||
const inpNome = document.getElementById('cli_nome');
|
||||
const inpCnpj = document.getElementById('cli_cnpj');
|
||||
const selAtv = document.getElementById('cli_ativo');
|
||||
|
||||
document.getElementById('modal-titulo').textContent = 'Novo Cliente';
|
||||
|
||||
inpId.value = '';
|
||||
inpNome.value = '';
|
||||
inpCnpj.value = ''; // <<< nada de mask.textContent
|
||||
|
||||
selAtv.value = 'true';
|
||||
setStatusUI(true);
|
||||
|
||||
// novo: não mostra o select de status
|
||||
grpStatus.style.display = 'none';
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
setTimeout(()=> inpNome.focus(), 0);
|
||||
}
|
||||
|
||||
function abrirModalEditar(c){
|
||||
const modal = document.getElementById('modal');
|
||||
const grpStatus = document.getElementById('grp_status');
|
||||
const inpId = document.getElementById('cli_id');
|
||||
const inpNome = document.getElementById('cli_nome');
|
||||
const inpCnpj = document.getElementById('cli_cnpj');
|
||||
const selAtv = document.getElementById('cli_ativo');
|
||||
|
||||
document.getElementById('modal-titulo').textContent = 'Editar Cliente';
|
||||
|
||||
inpId.value = c.id || '';
|
||||
inpNome.value = c.nome_fantasia || '';
|
||||
// Preenche já mascarado no próprio input
|
||||
inpCnpj.value = formatCNPJ(c.cnpj || ''); // <<< em vez de mask.textContent
|
||||
|
||||
grpStatus.style.display = ''; // mostra no editar
|
||||
const ativo = !!c.ativo;
|
||||
selAtv.value = ativo ? 'true' : 'false';
|
||||
setStatusUI(ativo);
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
setTimeout(()=> inpNome.focus(), 0);
|
||||
}
|
||||
|
||||
function fecharModal(){
|
||||
const modal = document.getElementById('modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
// sincroniza barra quando trocar o select (só visível no modo edição)
|
||||
document.getElementById('cli_ativo').addEventListener('change', (e)=>{
|
||||
setStatusUI(e.target.value === 'true');
|
||||
});
|
||||
|
||||
// LIGA o botão "Novo Cliente"
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const btnNovo = document.getElementById('btnNovo');
|
||||
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
|
||||
});
|
||||
|
||||
|
||||
function formatCNPJ(d){ // 14 dígitos -> 00.000.000/0000-00
|
||||
d = onlyDigits(d).slice(0,14);
|
||||
let out = '';
|
||||
if (d.length > 0) out += d.substring(0,2);
|
||||
if (d.length > 2) out += '.' + d.substring(2,5);
|
||||
if (d.length > 5) out += '.' + d.substring(5,8);
|
||||
if (d.length > 8) out += '/' + d.substring(8,12);
|
||||
if (d.length > 12) out += '-' + d.substring(12,14);
|
||||
return out;
|
||||
}
|
||||
|
||||
function maskCNPJ(ev){
|
||||
const el = ev.target;
|
||||
const caret = el.selectionStart;
|
||||
const before = el.value;
|
||||
el.value = formatCNPJ(el.value);
|
||||
// caret simples (bom o suficiente aqui)
|
||||
const diff = el.value.length - before.length;
|
||||
el.selectionStart = el.selectionEnd = Math.max(0, (caret||0) + diff);
|
||||
}
|
||||
|
||||
// valida CNPJ com dígitos verificadores
|
||||
function isValidCNPJ(v){
|
||||
const c = onlyDigits(v);
|
||||
if (c.length !== 14) return false;
|
||||
if (/^(\d)\1{13}$/.test(c)) return false; // todos iguais
|
||||
|
||||
const calc = (base) => {
|
||||
const nums = base.split('').map(n=>parseInt(n,10));
|
||||
const pesos = [];
|
||||
for (let i=0;i<nums.length;i++){
|
||||
pesos.push( (nums.length+1-i) > 9 ? (nums.length+1-i)-8 : (nums.length+1-i) );
|
||||
}
|
||||
let soma = 0;
|
||||
for (let i=0;i<nums.length;i++) soma += nums[i] * pesos[i];
|
||||
const r = soma % 11;
|
||||
return (r < 2) ? 0 : (11 - r);
|
||||
};
|
||||
|
||||
const d1 = calc(c.substring(0,12));
|
||||
const d2 = calc(c.substring(0,12) + d1);
|
||||
return c.endsWith(`${d1}${d2}`);
|
||||
}
|
||||
|
||||
// ligar máscara
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const cnpjEl = document.getElementById('cli_cnpj');
|
||||
if (cnpjEl){
|
||||
cnpjEl.addEventListener('input', maskCNPJ);
|
||||
}
|
||||
});
|
||||
|
||||
async function salvarModal(e){
|
||||
e.preventDefault();
|
||||
|
||||
const btnSalvar = document.querySelector('#form-modal .btn.btn-primary[type="submit"]');
|
||||
if (btnSalvar) btnSalvar.disabled = true;
|
||||
|
||||
try {
|
||||
const nome = document.getElementById('cli_nome').value.trim();
|
||||
const cnpjEl = document.getElementById('cli_cnpj');
|
||||
const ativo = document.getElementById('cli_ativo').value === 'true';
|
||||
const id = document.getElementById('cli_id').value || null;
|
||||
|
||||
const cnpjDigits = onlyDigits(cnpjEl.value);
|
||||
|
||||
if (!nome){
|
||||
alert('Informe o nome fantasia.');
|
||||
document.getElementById('cli_nome').focus();
|
||||
return;
|
||||
}
|
||||
if (cnpjDigits && !isValidCNPJ(cnpjEl.value)){
|
||||
alert('CNPJ inválido.');
|
||||
cnpjEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { nome_fantasia: nome, cnpj: cnpjDigits || null, ativo };
|
||||
const url = id ? `/api/clientes/${id}` : '/api/clientes';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (r.status === 409) {
|
||||
const { detail } = await r.json().catch(() => ({ detail: 'CNPJ já cadastrado.' }));
|
||||
alert(detail || 'CNPJ já cadastrado.');
|
||||
return;
|
||||
}
|
||||
if (!r.ok){
|
||||
alert('Erro ao salvar.');
|
||||
return;
|
||||
}
|
||||
|
||||
fecharModal();
|
||||
carregar();
|
||||
} finally {
|
||||
if (btnSalvar) btnSalvar.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Botão Novo Cliente
|
||||
const btnNovo = document.getElementById('btnNovo');
|
||||
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
|
||||
|
||||
// Campo de busca
|
||||
const busca = document.getElementById('busca');
|
||||
const debounce = (fn, wait=250) => {
|
||||
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
|
||||
};
|
||||
|
||||
if (busca) {
|
||||
busca.addEventListener('input', debounce(() => {
|
||||
carregar(busca.value.trim()); // <-- agora consulta o backend a cada digitação (com debounce)
|
||||
}, 250));
|
||||
}
|
||||
|
||||
|
||||
// Máscara no CNPJ do modal
|
||||
const cnpjEl = document.getElementById('cli_cnpj');
|
||||
if (cnpjEl) cnpjEl.addEventListener('input', maskCNPJ);
|
||||
|
||||
// Carregar clientes na tabela ao abrir a página
|
||||
carregar();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user