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:
@@ -24,10 +24,12 @@ from fastapi.responses import FileResponse
|
|||||||
from app.models import Fatura, SelicMensal, ParametrosFormula
|
from app.models import Fatura, SelicMensal, ParametrosFormula
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from app.utils import avaliar_formula
|
from app.utils import avaliar_formula
|
||||||
|
from app.routes import clientes
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
app.state.templates = templates
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
UPLOAD_DIR = "uploads/temp"
|
UPLOAD_DIR = "uploads/temp"
|
||||||
@@ -419,6 +421,7 @@ async def export_excel():
|
|||||||
|
|
||||||
from app.parametros import router as parametros_router
|
from app.parametros import router as parametros_router
|
||||||
app.include_router(parametros_router)
|
app.include_router(parametros_router)
|
||||||
|
app.include_router(clientes.router)
|
||||||
|
|
||||||
def is_homolog():
|
def is_homolog():
|
||||||
return os.getenv("APP_ENV", "dev") == "homolog"
|
return os.getenv("APP_ENV", "dev") == "homolog"
|
||||||
|
|||||||
@@ -80,3 +80,15 @@ class SelicMensal(Base):
|
|||||||
ano = Column(Integer, primary_key=True)
|
ano = Column(Integer, primary_key=True)
|
||||||
mes = Column(Integer, primary_key=True)
|
mes = Column(Integer, primary_key=True)
|
||||||
percentual = Column(Numeric(6, 4))
|
percentual = Column(Numeric(6, 4))
|
||||||
|
|
||||||
|
class Cliente(Base):
|
||||||
|
__tablename__ = "clientes"
|
||||||
|
__table_args__ = {"schema": "faturas"}
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
nome_fantasia = Column(String, nullable=False)
|
||||||
|
cnpj = Column(String(14), unique=True)
|
||||||
|
ativo = Column(Boolean, default=True)
|
||||||
|
data_criacao = Column(DateTime, default=datetime.utcnow)
|
||||||
|
data_atualizacao = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
72
app/routes/clientes.py
Normal file
72
app/routes/clientes.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# app/routes/clientes.py
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import Cliente
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from uuid import UUID
|
||||||
|
import uuid
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/clientes")
|
||||||
|
async def clientes_page(request: Request):
|
||||||
|
return request.app.state.templates.TemplateResponse("clientes.html", {"request": request})
|
||||||
|
|
||||||
|
class ClienteIn(BaseModel):
|
||||||
|
nome_fantasia: str
|
||||||
|
cnpj: str | None = None
|
||||||
|
ativo: bool = True
|
||||||
|
|
||||||
|
@router.get("/api/clientes")
|
||||||
|
async def listar(
|
||||||
|
busca: str = Query(default="", description="Filtro por nome ou CNPJ"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
stmt = select(Cliente).order_by(Cliente.nome_fantasia)
|
||||||
|
if busca:
|
||||||
|
pattern = f"%{busca}%"
|
||||||
|
stmt = select(Cliente).where(
|
||||||
|
or_(
|
||||||
|
Cliente.nome_fantasia.ilike(pattern),
|
||||||
|
Cliente.cnpj.ilike(pattern),
|
||||||
|
)
|
||||||
|
).order_by(Cliente.nome_fantasia)
|
||||||
|
|
||||||
|
res = await session.execute(stmt)
|
||||||
|
clientes = res.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(c.id),
|
||||||
|
"nome_fantasia": c.nome_fantasia,
|
||||||
|
"cnpj": c.cnpj,
|
||||||
|
"ativo": c.ativo,
|
||||||
|
}
|
||||||
|
for c in clientes
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/api/clientes")
|
||||||
|
async def criar_cliente(body: ClienteIn, session: AsyncSession = Depends(get_session)):
|
||||||
|
cliente = Cliente(**body.dict())
|
||||||
|
session.add(cliente)
|
||||||
|
await session.commit()
|
||||||
|
return {"id": str(cliente.id)}
|
||||||
|
|
||||||
|
@router.put("/api/clientes/{id}")
|
||||||
|
async def editar_cliente(id: UUID, body: ClienteIn, session: AsyncSession = Depends(get_session)):
|
||||||
|
await session.execute(
|
||||||
|
Cliente.__table__.update().where(Cliente.id == id).values(**body.dict())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.delete("/api/clientes/{id}")
|
||||||
|
async def excluir(id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
||||||
|
obj = await session.get(Cliente, id)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(404, "Cliente não encontrado")
|
||||||
|
await session.delete(obj)
|
||||||
|
await session.commit()
|
||||||
|
return {"ok": True}
|
||||||
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 %}
|
||||||
@@ -154,6 +154,10 @@
|
|||||||
<i class="fas fa-tachometer-alt"></i>
|
<i class="fas fa-tachometer-alt"></i>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/clientes" class="menu-item">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
|
<span>Clientes</span>
|
||||||
|
</a>
|
||||||
<a href="/upload" class="menu-item">
|
<a href="/upload" class="menu-item">
|
||||||
<i class="fas fa-upload"></i>
|
<i class="fas fa-upload"></i>
|
||||||
<span>Upload</span>
|
<span>Upload</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user