Files
app_faturas/app/templates/clientes.html

503 lines
15 KiB
HTML
Raw Normal View History

2025-08-09 19:51:14 -03:00
{% 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 %}