Add 'gerenciador/' from commit '34cc9c75a57dedbf61a6e6a1ba1f35f76e72434b'

git-subtree-dir: gerenciador
git-subtree-mainline: f4f546c928
git-subtree-split: 34cc9c75a5
This commit is contained in:
gti
2026-09-29 14:43:12 -03:00
84 changed files with 8906 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
<!-- app/templates/base.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titulo %}Painel de Clientes{% endblock %}</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/estilo.css?v={{ request.state.asset_versao }}">
<script>
(function () {
var tema = localStorage.getItem("tema");
if (tema === "claro" || tema === "escuro") {
document.documentElement.setAttribute("data-tema", tema);
}
})();
</script>
</head>
<body>
<div class="layout">
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-topo">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="sidebar-logo">
<span class="sidebar-titulo">Painel de Clientes</span>
</div>
<nav class="sidebar-nav">
<a href="/clientes" class="{{ 'ativo' if request.url.path.startswith('/clientes') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
<span>Clientes</span>
</a>
<a href="/gerador-chave" class="{{ 'ativo' if request.url.path.startswith('/gerador-chave') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/></svg>
<span>Gerador de chave</span>
</a>
{% if request.state.usuario and request.state.usuario.eh_admin %}
<a href="/funcionarios" class="{{ 'ativo' if request.url.path.startswith('/funcionarios') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="7" r="4"/><path d="M2 21v-2a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v2M17 11l2 2 4-4"/></svg>
<span>Funcionários</span>
</a>
{% endif %}
<a href="/log" class="{{ 'ativo' if request.url.path.startswith('/log') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/><path d="M9 15h6M9 11h10M9 7h4"/></svg>
<span>Log</span>
</a>
<a href="/sso/fiscal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<span>Fiscal</span>
</a>
{% if request.state.usuario %}
<a href="/minha-conta" class="{{ 'ativo' if request.url.path.startswith('/minha-conta') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>
<span>Minha conta</span>
</a>
{% endif %}
</nav>
<div class="sidebar-rodape">
{% if request.state.usuario %}
<div class="sidebar-usuario">{{ request.state.usuario.nome }}</div>
<button type="button" class="botao-tema" id="botao-tema" aria-label="Alternar tema claro/escuro">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<span>Tema claro/escuro</span>
</button>
<form method="post" action="/logout" class="form-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token or request.state.csrf_token }}">
<button type="submit" class="botao-sair">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
<span>Sair</span>
</button>
</form>
{% endif %}
<button type="button" class="botao-recolher" id="botao-recolher" aria-label="Recolher menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/></svg>
<span>Recolher</span>
</button>
</div>
</aside>
<div class="conteudo-area">
<header class="topbar-mobile">
<button type="button" class="topbar-menu-btn" id="btn-menu-mobile" aria-label="Abrir menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px;height:22px"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<span class="topbar-titulo">{{ self.titulo() }}</span>
</header>
<main class="conteudo">
{% if mensagem %}<div class="mensagem {{ mensagem_tipo or 'success' }}">{{ mensagem }}</div>{% endif %}
{% block conteudo %}{% endblock %}
</main>
</div>
</div>
<script src="/static/sidebar.js?v={{ request.state.asset_versao }}" defer></script>
<script src="/static/tema.js?v={{ request.state.asset_versao }}" defer></script>
</body>
</html>
@@ -0,0 +1,69 @@
<!-- app/templates/cliente_detalhe.html -->
{% extends "base.html" %}
{% block titulo %}{{ cliente.RAZAO }}{% endblock %}
{% block conteudo %}
<h1>{{ cliente.FANTASIA }} <small style="color:var(--cor-texto-secundario)">({{ cliente.CODIGO }})</small></h1>
<p style="color:var(--cor-texto-secundario)">{{ cliente.RAZAO }} — {{ cliente.CNPJ }}</p>
<p>
<a class="botao" href="/clientes/{{ cliente.ID }}/editar">Editar</a>
<form method="post" action="/clientes/{{ cliente.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este cliente?')">Excluir</button>
</form>
</p>
<h2>Contatos</h2>
<div class="tabela-wrap">
<table>
<tr><th>Nome</th><th>Telefone</th><th>E-mail</th><th></th></tr>
{% for ct in contatos %}
<tr>
<td>{{ ct.NOME }}</td><td>{{ ct.TELEFONE }}</td><td>{{ ct.EMAIL or "" }}</td>
<td>
<details style="display:inline-block">
<summary class="botao" style="display:inline-block;cursor:pointer">Editar</summary>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/editar" style="margin-top:8px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome-{{ ct.ID }}">Nome</label>
<input type="text" id="nome-{{ ct.ID }}" name="nome" value="{{ ct.NOME or '' }}">
<label for="telefone-{{ ct.ID }}">Telefone</label>
<input type="text" id="telefone-{{ ct.ID }}" name="telefone" value="{{ ct.TELEFONE }}" required>
<label for="email-{{ ct.ID }}">E-mail</label>
<input type="text" id="email-{{ ct.ID }}" name="email" value="{{ ct.EMAIL or '' }}">
<button type="submit">Salvar</button>
</form>
</details>
<form method="post" action="/clientes/{{ cliente.ID }}/contatos/{{ ct.ID }}/excluir" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Excluir este contato?')">Excluir</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
<div class="cartao">
<form method="post" action="/clientes/{{ cliente.ID }}/contatos">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nome">Nome</label>
<input type="text" id="nome" name="nome">
<label for="telefone">Telefone</label>
<input type="text" id="telefone" name="telefone" required>
<label for="email">E-mail</label>
<input type="text" id="email" name="email">
<button type="submit">Adicionar contato</button>
</form>
</div>
<h2>Status em Clientes_Chaves</h2>
{% if status_chaves %}
<div class="tabela-wrap">
<table>
<tr>{% for mes in status_chaves.meses %}<th>{{ mes }}</th>{% endfor %}</tr>
<tr>{% for mes, valor in status_chaves.meses.items() %}<td>{{ "✓" if valor else "—" }}</td>{% endfor %}</tr>
</table>
</div>
{% else %}
<p>Nenhuma linha em Clientes_Chaves para o código {{ cliente.CODIGO }}.</p>
{% endif %}
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
<!-- app/templates/cliente_form.html -->
{% extends "base.html" %}
{% block titulo %}{{ "Editar" if cliente else "Novo" }} cliente{% endblock %}
{% block conteudo %}
<h1>{{ "Editar" if cliente else "Novo" }} cliente</h1>
{% if erro_codigo_duplicado %}
<div class="mensagem danger">
O código {{ dados.CODIGO }} já está em uso por outro cliente — escolha outro código antes de salvar.
</div>
{% endif %}
{% if erro_cnpj_duplicado %}
<div class="mensagem danger">
O CNPJ/CPF {{ dados.CNPJ }} já está cadastrado para outro cliente.
</div>
{% endif %}
{% if aviso_dv_invalido %}
<div class="mensagem warning">
O dígito verificador do documento ({{ dados.CNPJ }}) não confere — confirme se digitou certo antes de salvar.
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for campo, valor in dados.items() %}<input type="hidden" name="{{ campo }}" value="{{ valor }}">{% endfor %}
<input type="hidden" name="confirmar_dv_invalido" value="1">
<button type="submit">Cadastrar mesmo assim</button>
</form>
</div>
{% endif %}
{% if erro_cnpj_invalido %}
<div class="mensagem danger">
O dígito verificador do CNPJ ({{ dados.CNPJ }}) não confere — corrija antes de salvar.
</div>
{% endif %}
{% if erro_gravacao %}
<div class="mensagem danger">
Não foi possível salvar: {{ erro_gravacao }}
</div>
{% endif %}
<div class="cartao">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid-form">
{% for campo in campos %}
{% set largura_total = campo in ["OBS"] %}
<div class="{{ 'grid-form-largura-total' if largura_total }}">
<label for="{{ campo }}">{{ campo }}{% if campo in campos_obrigatorios %} *{% endif %}</label>
{% if campo in ["DATA_CONTRATO", "VENCIMENTO_CONTRATO"] %}
<input type="date" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% elif campo in ["IMPLANTACAO", "MENSALIDADE", "JUROS_MORA", "JUROS_MULTA"] %}
<input type="number" step="0.01" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% elif campo == "ATIVO" %}
<select id="{{ campo }}" name="{{ campo }}">
<option value="1" {% if (dados[campo] if dados else "1")|string == "1" %}selected{% endif %}>Ativo</option>
<option value="0" {% if (dados[campo] if dados else "1")|string == "0" %}selected{% endif %}>Inativo</option>
</select>
{% else %}
<input type="text" id="{{ campo }}" name="{{ campo }}" value="{{ dados[campo] if dados else '' }}" {% if campo in campos_obrigatorios %}required{% endif %}>
{% if campo == "CNPJ" %}
<small id="cnpj-status" style="display:block;color:var(--cor-texto-secundario);margin-top:4px"></small>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
<p><small>* campos obrigatórios. Os demais podem ficar em branco — implantação/mensalidade/juros ficam 0,00 e as datas de contrato ficam com a data de hoje até serem ajustadas.</small></p>
<button type="submit" id="botao-salvar">Salvar</button>
</form>
</div>
<script>
(function () {
// Ao sair do campo CNPJ, busca os dados na Receita (via BrasilAPI, ver
// app/cnpj_lookup.py) e preenche os campos abaixo — só os que estiverem
// vazios, nunca sobrescreve o que já foi digitado. Não faz nada para CPF
// (a rota /clientes/consulta-cnpj só responde para CNPJ).
var $cnpj = document.getElementById('CNPJ');
var $status = document.getElementById('cnpj-status');
if (!$cnpj) return;
var $botaoSalvar = document.getElementById('botao-salvar');
// Mesma fórmula de dígito verificador de app/normalize.py
// (_digito_verificador + validar_dv_cnpj) — módulo 11 sobre valor ASCII
// − 48 de cada caractere, suporta CNPJ numérico e o alfanumérico
// vigente desde 31/07/2026.
var PESOS_CNPJ_DV1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
var PESOS_CNPJ_DV2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
function digitoVerificador(valores, pesos) {
var soma = 0;
for (var i = 0; i < valores.length; i++) soma += valores[i] * pesos[i];
var resto = soma % 11;
return resto < 2 ? 0 : 11 - resto;
}
function valorCaractere(c) {
return c.charCodeAt(0) - 48;
}
function validarDvCnpj(cnpj14) {
var base = [];
for (var i = 0; i < 12; i++) base.push(valorCaractere(cnpj14[i]));
var dvInformado = [parseInt(cnpj14[12], 10), parseInt(cnpj14[13], 10)];
var dv1 = digitoVerificador(base, PESOS_CNPJ_DV1);
var dv2 = digitoVerificador(base.concat([dv1]), PESOS_CNPJ_DV2);
return dvInformado[0] === dv1 && dvInformado[1] === dv2;
}
function detectarEValidarDocumento() {
var limpo = normalizarCnpj($cnpj.value);
if ($botaoSalvar) $botaoSalvar.disabled = false;
if (/^[0-9]{11}$/.test(limpo)) {
if ($status) {
$status.textContent = 'CPF detectado.';
$status.style.color = 'var(--cor-texto-secundario)';
}
return;
}
if (/^[0-9A-Z]{12}[0-9]{2}$/.test(limpo)) {
if (validarDvCnpj(limpo)) {
if ($status) {
$status.textContent = 'CNPJ detectado.';
$status.style.color = 'var(--cor-texto-secundario)';
}
} else {
if ($status) {
$status.textContent = 'CNPJ inválido (dígito verificador não confere).';
$status.style.color = 'var(--cor-perigo)';
}
if ($botaoSalvar) $botaoSalvar.disabled = true;
}
return;
}
// Tamanho ainda incompleto/indeterminado: não bloqueia, não rotula.
if ($status) $status.textContent = '';
}
$cnpj.addEventListener('input', detectarEValidarDocumento);
detectarEValidarDocumento();
function aplicarMascaraTelefone(elemento, digitosAposDdd) {
elemento.addEventListener('input', function () {
var digitos = elemento.value.replace(/\D/g, '');
// DDI 55 colado: detecta por tamanho total (12 ou 13 dígitos), não
// pelo tamanho esperado do campo específico — um número de 12
// dígitos colado no campo de celular ainda deve ter o 55 removido.
if ((digitos.length === 12 || digitos.length === 13) && digitos.slice(0, 2) === '55') {
digitos = digitos.slice(2);
}
digitos = digitos.slice(0, digitosAposDdd);
var ddd = digitos.slice(0, 2);
var resto = digitos.slice(2);
var meio = resto.slice(0, digitosAposDdd - 6);
var fim = resto.slice(digitosAposDdd - 6);
var formatado = '';
if (ddd) formatado += '(' + ddd;
if (ddd.length === 2) formatado += ') ';
formatado += meio;
if (fim) formatado += '-' + fim;
elemento.value = formatado;
});
}
var $telefone = document.getElementById('TELEFONE');
var $celular = document.getElementById('CELULAR');
// TELEFONE: DDD(2) + 8 dígitos = 10 dígitos após o DDI.
if ($telefone) aplicarMascaraTelefone($telefone, 10);
// CELULAR: DDD(2) + 9 dígitos (com o 9 na frente) = 11 dígitos após o DDI.
if ($celular) aplicarMascaraTelefone($celular, 11);
var $uf = document.getElementById('UF');
if ($uf) {
$uf.addEventListener('input', function () {
$uf.value = $uf.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
});
}
var $cep = document.getElementById('CEP');
if ($cep) {
$cep.addEventListener('input', function () {
var digitos = $cep.value.replace(/\D/g, '').slice(0, 8);
$cep.value = digitos.length > 5 ? digitos.slice(0, 5) + '-' + digitos.slice(5) : digitos;
});
}
var CAMPOS_PARA_PREENCHER = ['RAZAO', 'FANTASIA', 'ENDERECO', 'CIDADE', 'BAIRRO', 'UF', 'CEP', 'TELEFONE', 'EMAIL'];
function normalizarCnpj(valor) {
return (valor || '').toUpperCase().replace(/[^0-9A-Z]/g, '');
}
async function buscarCnpj() {
var limpo = normalizarCnpj($cnpj.value);
if (limpo.length !== 14) return;
if (limpo.length === 14 && !validarDvCnpj(limpo)) return;
if ($status) $status.textContent = 'Buscando dados do CNPJ...';
try {
var resposta = await fetch('/clientes/consulta-cnpj/' + encodeURIComponent(limpo));
var corpo = await resposta.json();
if (!resposta.ok || !corpo.encontrado) {
if ($status) $status.textContent = 'CNPJ não encontrado na Receita — preencha manualmente.';
return;
}
var preencheu = false;
CAMPOS_PARA_PREENCHER.forEach(function (campo) {
var $input = document.getElementById(campo);
if ($input && !$input.value && corpo.dados[campo]) {
$input.value = corpo.dados[campo];
$input.dispatchEvent(new Event('input'));
preencheu = true;
}
});
if ($status) {
$status.textContent = preencheu
? 'Dados preenchidos automaticamente a partir do CNPJ — confira antes de salvar.'
: 'CNPJ encontrado (campos já preenchidos não foram alterados).';
}
} catch (erro) {
if ($status) $status.textContent = '';
}
}
$cnpj.addEventListener('blur', buscarCnpj);
})();
</script>
{% endblock %}
@@ -0,0 +1,181 @@
<!-- app/templates/clientes_lista.html -->
{% extends "base.html" %}
{% block titulo %}Clientes{% endblock %}
{% block conteudo %}
<h1>Clientes</h1>
<div class="cartao">
<form method="get" action="/clientes">
<label for="ordenar_por">Buscar/ordenar por</label>
<select id="ordenar_por" name="ordenar_por">
<option value="razao" {% if ordenar_por == "razao" %}selected{% endif %}>Razão social</option>
<option value="codigo" {% if ordenar_por == "codigo" %}selected{% endif %}>Código</option>
<option value="fantasia" {% if ordenar_por == "fantasia" %}selected{% endif %}>Fantasia</option>
<option value="cnpj" {% if ordenar_por == "cnpj" %}selected{% endif %}>CNPJ</option>
</select>
<input type="text" name="busca" value="{{ busca or '' }}" placeholder="Buscar só no campo escolhido acima">
<label for="status">Status</label>
<select id="status" name="status">
<option value="ativos" {% if status == "ativos" %}selected{% endif %}>Ativos</option>
<option value="inativos" {% if status == "inativos" %}selected{% endif %}>Desativados ou inativos</option>
<option value="todos" {% if status == "todos" %}selected{% endif %}>Todos</option>
</select>
<button type="submit">Buscar</button>
</form>
</div>
<p><a class="botao" href="/clientes/novo">+ Novo cliente</a></p>
{% set busca_qs = ('&busca=' + (busca | urlencode)) if busca else '' %}
{% set qs_base = ('&ordenar_por=' + ordenar_por + '&direcao=' + direcao + '&por_pagina=' + (por_pagina | string) + '&status=' + status + busca_qs) | safe %}
<div class="tabela-wrap">
<table class="table-clientes">
<colgroup>
<col class="col-codigo">
<col class="col-razao">
<col class="col-fantasia">
<col class="col-cnpj">
<col class="col-status">
<col class="col-acoes">
</colgroup>
<tr>
{% set colunas_tabela = [("codigo", "Código", "col-codigo"), ("razao", "Razão", "col-razao"), ("fantasia", "Fantasia", "col-fantasia"), ("cnpj", "CNPJ", "col-cnpj")] %}
{% for campo, rotulo, classe_coluna in colunas_tabela %}
{% set nova_direcao = "desc" if (ordenar_por == campo and direcao == "asc") else "asc" %}
<th class="{{ classe_coluna }}">
<a href="/clientes?ordenar_por={{ campo }}&direcao={{ nova_direcao }}&pagina=1&por_pagina={{ por_pagina }}&status={{ status }}{{ busca_qs }}">
{{ rotulo }}{% if ordenar_por == campo %}{{ " ▲" if direcao == "asc" else " ▼" }}{% endif %}
</a>
</th>
{% endfor %}
<th class="col-status">Status</th>
<th class="col-acoes"></th>
</tr>
{% for c in clientes %}
<tr>
<td class="col-codigo">{{ c.CODIGO }}</td>
<td class="col-razao">{{ c.RAZAO }}</td>
<td class="col-fantasia">{{ c.FANTASIA }}</td>
<td class="col-cnpj">{{ c.CNPJ }}</td>
<td class="col-status">
<form method="post" action="/clientes/{{ c.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirecionar_para" value="{{ redirecionar_para }}">
<button
type="submit"
class="status-dot {{ 'status-dot-ativo' if c.ATIVO else 'status-dot-inativo' }}"
title="{{ 'Ativo — clique para desativar' if c.ATIVO else 'Inativo — clique para ativar' }}"
onclick='return confirm({{ ((("Desativar" if c.ATIVO else "Reativar") + " o cliente " + c.RAZAO + "?")) | tojson }})'
>
<span class="sr-only">{{ "Ativo" if c.ATIVO else "Inativo" }}</span>
</button>
</form>
</td>
<td class="col-acoes">
<a class="botao botao-tabela" href="/clientes/{{ c.ID }}">Abrir</a>
<button type="button" class="botao botao-tabela botao-gerar-chave" data-codigo="{{ c.CODIGO }}" onclick="gerarChaveReativacao(this)">Gerar chave</button>
<span class="chave-feedback" aria-live="polite"></span>
</td>
</tr>
{% endfor %}
</table>
</div>
<div class="paginacao-rodape">
<form method="get" action="/clientes" class="paginacao-por-pagina">
<input type="hidden" name="ordenar_por" value="{{ ordenar_por }}">
<input type="hidden" name="direcao" value="{{ direcao }}">
<input type="hidden" name="pagina" value="1">
<input type="hidden" name="status" value="{{ status }}">
{% if busca %}<input type="hidden" name="busca" value="{{ busca }}">{% endif %}
<label for="por_pagina">Linhas por página</label>
<select id="por_pagina" name="por_pagina" onchange="this.form.submit()">
{% for opcao in opcoes_por_pagina %}
<option value="{{ opcao }}" {% if opcao == por_pagina %}selected{% endif %}>{{ opcao }}</option>
{% endfor %}
</select>
</form>
<span>Exibindo {{ primeiro_item }} a {{ ultimo_item }} de {{ total }} clientes</span>
{% if total_paginas and total_paginas > 1 %}
<div class="paginacao">
{% if pagina > 1 %}
<a class="botao" href="/clientes?pagina=1{{ qs_base }}" aria-label="Primeira página">&laquo;</a>
<a class="botao" href="/clientes?pagina={{ pagina - 1 }}{{ qs_base }}" aria-label="Página anterior">&lsaquo;</a>
{% endif %}
{% for p in janela_paginas %}
{% if p is none %}
<span class="paginacao-reticencias">&hellip;</span>
{% elif p == pagina %}
<span class="paginacao-atual">{{ p }}</span>
{% else %}
<a class="botao" href="/clientes?pagina={{ p }}{{ qs_base }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if pagina < total_paginas %}
<a class="botao" href="/clientes?pagina={{ pagina + 1 }}{{ qs_base }}" aria-label="Próxima página">&rsaquo;</a>
<a class="botao" href="/clientes?pagina={{ total_paginas }}{{ qs_base }}" aria-label="Última página">&raquo;</a>
{% endif %}
</div>
{% endif %}
</div>
<script>
// Gera a chave de reativação do mês vigente pro cliente da linha
// clicada, reaproveitando o endpoint já usado pelo gerador manual
// (/gerador-chave/gerar) — o algoritmo de cifra roda só no backend
// (app/gerador_chave.py), esta página só manda código+mês/ano e
// copia o resultado.
const CSRF_TOKEN = "{{ csrf_token }}";
function _mesAnoAtual() {
const hoje = new Date();
return `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
}
async function gerarChaveReativacao(botao) {
const codigo = botao.dataset.codigo;
const feedback = botao.parentElement.querySelector('.chave-feedback');
botao.disabled = true;
feedback.textContent = '';
feedback.classList.remove('chave-feedback-erro');
const corpo = new URLSearchParams({
csrf_token: CSRF_TOKEN,
tipo: 'reativacao',
codigo: codigo,
referencia: _mesAnoAtual(),
});
let resposta, dados;
try {
resposta = await fetch('/gerador-chave/gerar', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo,
});
dados = await resposta.json();
} catch (e) {
feedback.textContent = 'Falha de rede — tente de novo.';
feedback.classList.add('chave-feedback-erro');
botao.disabled = false;
return;
}
if (!resposta.ok) {
feedback.textContent = dados.erro || 'Não foi possível gerar a chave.';
feedback.classList.add('chave-feedback-erro');
botao.disabled = false;
return;
}
try {
await navigator.clipboard.writeText(dados.chave);
feedback.textContent = 'Copiado!';
} catch (e) {
feedback.textContent = 'Chave gerada, mas não copiou automaticamente.';
feedback.classList.add('chave-feedback-erro');
}
botao.disabled = false;
setTimeout(() => {
feedback.textContent = '';
feedback.classList.remove('chave-feedback-erro');
}, 1500);
}
</script>
{% endblock %}
@@ -0,0 +1,46 @@
<!-- app/templates/funcionario_form.html -->
{% extends "base.html" %}
{% block titulo %}{{ "Editar" if funcionario else "Novo" }} funcionário{% endblock %}
{% block conteudo %}
<h1>{{ "Editar" if funcionario else "Novo" }} funcionário</h1>
{% if erro %}
<div class="mensagem danger">{{ erro }}</div>
{% endif %}
<div class="cartao">
<form method="post" action="{{ '/funcionarios/' + funcionario.ID|string + '/editar' if funcionario else '/funcionarios/novo' }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid-form">
<div>
<label for="nome">Nome</label>
<input type="text" id="nome" name="nome" value="{{ dados.NOME if dados else (funcionario.NOME if funcionario else '') }}" required>
</div>
<div>
<label for="login">Login</label>
<input type="text" id="login" name="login" value="{{ dados.LOGIN if dados else (funcionario.LOGIN if funcionario else '') }}" required>
</div>
<div>
<label for="perfil">Perfil</label>
{% set perfil_atual = dados.PERFIL if dados else (funcionario.PERFIL if funcionario else 'PADRAO') %}
<select id="perfil" name="perfil">
<option value="PADRAO" {{ "selected" if perfil_atual == "PADRAO" else "" }}>Padrão</option>
<option value="ADMIN" {{ "selected" if perfil_atual == "ADMIN" else "" }}>Administrador</option>
</select>
</div>
{% if not funcionario %}
<div>
<label for="senha">Senha</label>
<input type="password" id="senha" name="senha" required>
</div>
<div>
<label for="confirmar_senha">Confirmar senha</label>
<input type="password" id="confirmar_senha" name="confirmar_senha" required>
</div>
{% endif %}
</div>
<button type="submit">Salvar</button>
</form>
</div>
{% if funcionario %}
<p><small>Para trocar a senha, use "Redefinir senha" na lista de funcionários.</small></p>
{% endif %}
{% endblock %}
@@ -0,0 +1,40 @@
<!-- app/templates/funcionarios_lista.html -->
{% extends "base.html" %}
{% block titulo %}Funcionários{% endblock %}
{% block conteudo %}
<h1>Funcionários</h1>
<p><a class="botao" href="/funcionarios/novo">+ Novo funcionário</a></p>
<div class="tabela-wrap">
<table>
<tr><th>Nome</th><th>Login</th><th>Perfil</th><th>Status</th><th></th></tr>
{% for f in funcionarios %}
<tr>
<td>{{ f.NOME }}</td>
<td>{{ f.LOGIN }}</td>
<td><span class="badge {{ 'badge-acento' if f.PERFIL == 'ADMIN' else 'badge-neutro' }}">{{ "Administrador" if f.PERFIL == "ADMIN" else "Padrão" }}</span></td>
<td><span class="badge {{ 'badge-sucesso' if f.ATIVO else 'badge-neutro' }}">{{ "Ativo" if f.ATIVO else "Inativo" }}</span></td>
<td>
<a class="botao" href="/funcionarios/{{ f.ID }}/editar">Editar</a>
<details style="display:inline-block">
<summary class="botao" style="display:inline-block;cursor:pointer">Redefinir senha</summary>
<form method="post" action="/funcionarios/{{ f.ID }}/redefinir-senha" style="margin-top:8px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="nova_senha-{{ f.ID }}">Nova senha</label>
<input type="password" id="nova_senha-{{ f.ID }}" name="nova_senha" required>
<label for="confirmar_nova_senha-{{ f.ID }}">Confirmar nova senha</label>
<input type="password" id="confirmar_nova_senha-{{ f.ID }}" name="confirmar_nova_senha" required>
<button type="submit">Salvar senha</button>
</form>
</details>
<form method="post" action="/funcionarios/{{ f.ID }}/alternar-ativo" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('{{ "Desativar" if f.ATIVO else "Reativar" }} este funcionário?')">
{{ "Desativar" if f.ATIVO else "Reativar" }}
</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block titulo %}Gerador de Chave{% endblock %}
{% block conteudo %}
<div class="cartao" style="max-width:420px;margin:0 auto;">
<h1 style="text-align:center;">Gerador de Chave</h1>
<label for="tipo">Tipo de chave</label>
<select id="tipo">
<option value="reativacao">Reativação (mês/ano)</option>
<option value="instalacao">Instalação (aluguel/compra)</option>
</select>
<label for="codigo">Código do cliente</label>
<input type="text" id="codigo" placeholder="Ex: 000123" maxlength="6" inputmode="numeric">
<div id="campos-reativacao">
<label for="referencia">Mês/ano de referência</label>
<input type="month" id="referencia" min="2000-01" max="2100-12">
</div>
<div id="campos-instalacao" style="display:none;">
<label for="modalidade">Modalidade</label>
<select id="modalidade">
<option value="ALUGUEL">Aluguel</option>
<option value="COMPRA">Compra</option>
</select>
</div>
<button id="gerar">Gerar chave</button>
<div id="erro" style="color:var(--cor-perigo);margin-top:14px;display:none;"></div>
<div id="resultado" style="margin-top:20px;padding:14px;border-radius:8px;background:var(--cor-superficie-alt);border:1px solid var(--cor-borda);border-left:3px solid var(--cor-acento);word-break:break-all;font-family:'Courier New',monospace;color:var(--cor-acento-texto);display:none;"></div>
<button id="copiar" style="display:none;background:transparent;border:1px solid var(--cor-borda);color:var(--cor-texto-secundario);width:auto;padding:6px 10px;">Copiar</button>
</div>
<script>
// O algoritmo de geração de chave roda só no backend (app/gerador_chave.py)
// — esta página só manda os dados do formulário pro endpoint e mostra o
// resultado. Nunca reintroduza a lógica de cifra aqui: qualquer pessoa com
// acesso à página consegue ler o código-fonte JS pelo navegador.
const CSRF_TOKEN = "{{ csrf_token }}";
const $tipo = document.getElementById('tipo');
const $codigo = document.getElementById('codigo');
const $referencia = document.getElementById('referencia');
const $modalidade = document.getElementById('modalidade');
const $camposReativacao = document.getElementById('campos-reativacao');
const $camposInstalacao = document.getElementById('campos-instalacao');
const $erro = document.getElementById('erro');
const $resultado = document.getElementById('resultado');
const $copiar = document.getElementById('copiar');
$codigo.addEventListener('input', () => {
$codigo.value = $codigo.value.replace(/\D/g, '');
});
(function preencherMesAtual() {
const hoje = new Date();
$referencia.value = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
})();
$tipo.addEventListener('change', () => {
const ehReativacao = $tipo.value === 'reativacao';
$camposReativacao.style.display = ehReativacao ? 'block' : 'none';
$camposInstalacao.style.display = ehReativacao ? 'none' : 'block';
});
function mostrarErro(msg) {
$erro.textContent = msg;
$erro.style.display = 'block';
$resultado.style.display = 'none';
$copiar.style.display = 'none';
}
document.getElementById('gerar').addEventListener('click', async () => {
$erro.style.display = 'none';
const corpo = new URLSearchParams({
csrf_token: CSRF_TOKEN,
tipo: $tipo.value,
codigo: $codigo.value,
referencia: $referencia.value,
modalidade: $modalidade.value,
});
let resposta, dados;
try {
resposta = await fetch('/gerador-chave/gerar', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: corpo,
});
dados = await resposta.json();
} catch (e) {
return mostrarErro('Não foi possível falar com o servidor — tente de novo.');
}
if (!resposta.ok) return mostrarErro(dados.erro || 'Não foi possível gerar a chave.');
$resultado.textContent = dados.chave;
$resultado.style.display = 'block';
$copiar.style.display = 'inline-block';
});
$copiar.addEventListener('click', () => {
navigator.clipboard.writeText($resultado.textContent).then(() => {
$copiar.textContent = 'Copiado!';
setTimeout(() => { $copiar.textContent = 'Copiar'; }, 1500);
});
});
</script>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
<!-- app/templates/log.html -->
{% extends "base.html" %}
{% block titulo %}Log{% endblock %}
{% block conteudo %}
<h1>Log</h1>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Ações no painel</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if eh_admin %}
<form method="post" action="/log/limpar" style="margin-bottom:14px">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" onclick="return confirm('Limpar todo o histórico de ações? Isso não pode ser desfeito.')">Limpar log</button>
</form>
{% endif %}
{% if acoes %}
<div class="tabela-wrap">
<table>
<tr><th>Quando</th><th>Usuário</th><th>Ação</th><th>Entidade</th><th>Status</th><th>Detalhe</th></tr>
{% for a in acoes %}
<tr>
<td>{{ a.CRIADO_EM }}</td>
<td>{{ a.USUARIO }}</td>
<td>{{ a.ACAO }}</td>
<td>{{ a.ENTIDADE }}{% if a.ENTIDADE_ID %} #{{ a.ENTIDADE_ID }}{% endif %}</td>
<td><span class="badge {{ 'badge-sucesso' if a.SUCESSO else 'badge-neutro' }}">{{ "OK" if a.SUCESSO else "ERRO" }}</span></td>
<td>{{ a.DETALHE or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p>Nenhuma ação registrada ainda.</p>
{% endif %}
</div>
</details>
<details class="secao-log">
<summary>
<span class="secao-log-titulo">Envios automáticos de chave (n8n)</span>
<button type="button" class="botao-toggle-log" aria-expanded="false">Mostrar</button>
</summary>
<div class="secao-log-conteudo">
{% if not envios_configurado %}
<div class="cartao">
<p>
Integração com o n8n não configurada — defina <code>N8N_API_URL</code> e
<code>N8N_API_KEY</code> no ambiente (ver <code>.env.example</code>) para ver
aqui os envios automáticos de chave feitos pelo workflow do n8n.
</p>
</div>
{% elif not envios %}
<p>Nenhum envio encontrado (ou não foi possível consultar o n8n agora).</p>
{% else %}
<div class="tabela-wrap">
<table>
<tr>
<th>Data</th><th>Cliente</th><th>Código</th><th>Competência</th><th>Status</th>
<th>Telefones enviados</th><th>Telefones com erro</th>
</tr>
{% for e in envios %}
<tr>
<td>{{ e.data_processamento }}</td>
<td>{{ e.nome_cliente }}</td>
<td>{{ e.codigo_cliente }}</td>
<td>{{ e.competencia }}</td>
<td>{{ e.status }}</td>
<td>{{ e.telefones_enviados }}</td>
<td>{{ e.telefones_com_erro or "" }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
</div>
</details>
<script>
document.querySelectorAll(".secao-log").forEach(function (secao) {
var botao = secao.querySelector(".botao-toggle-log");
var atualizar = function () {
var aberto = secao.hasAttribute("open");
botao.textContent = aberto ? "Ocultar" : "Mostrar";
botao.setAttribute("aria-expanded", aberto ? "true" : "false");
};
// Um <button> dentro de <summary> é ele mesmo um elemento interativo:
// o navegador não propaga o clique para o toggle nativo do <details>
// quando o clique começa em outro controle interativo. Por isso o
// toggle precisa ser feito manualmente aqui.
botao.addEventListener("click", function (evento) {
evento.preventDefault();
secao.open = !secao.open;
atualizar();
});
secao.addEventListener("toggle", atualizar);
atualizar();
});
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
<!-- app/templates/login.html -->
<!DOCTYPE html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Entrar — Painel de Clientes</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/estilo.css?v={{ request.state.asset_versao }}">
<style>
body {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 16px;
background: radial-gradient(circle at top, var(--cor-superficie-alt), var(--cor-fundo));
}
.login-cartao { width: 100%; max-width: 360px; text-align: center; }
.login-logo { width: 64px; height: 64px; border-radius: 16px; margin-bottom: 12px; }
.login-cartao h1 { font-size: 1.3rem; margin-bottom: 4px; }
.login-subtitulo { color: var(--cor-texto-secundario); font-size: 0.9rem; margin: 0 0 20px; }
.login-cartao form { text-align: left; }
.login-cartao > form > button[type="submit"] { width: 100%; }
.campo-senha {
display: flex; align-items: center;
border: 1px solid var(--cor-borda); border-radius: 8px; background: var(--cor-fundo);
}
.campo-senha input {
flex: 1; min-width: 0; border: none; background: transparent;
}
.campo-senha input:focus-visible { outline: none; }
.campo-senha:focus-within { outline: 2px solid var(--cor-acento); outline-offset: 2px; }
.campo-senha .botao-ver-senha {
flex-shrink: 0; margin: 0; padding: 0 12px; width: auto; height: 100%;
background: transparent; border: none; color: var(--cor-texto-secundario);
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.campo-senha .botao-ver-senha:hover { color: var(--cor-texto); }
.campo-senha .botao-ver-senha svg { width: 18px; height: 18px; }
</style>
</head>
<body>
<div class="cartao login-cartao">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>Painel de Clientes</h1>
<p class="login-subtitulo">Entre com suas credenciais para acessar o sistema</p>
{% if erro %}<div class="mensagem danger">{{ erro }}</div>{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label for="usuario">Usuário</label>
<input type="text" id="usuario" name="usuario" required autofocus>
<label for="senha">Senha</label>
<div class="campo-senha">
<input type="password" id="senha" name="senha" required>
<button type="button" class="botao-ver-senha" id="botao-ver-senha" aria-label="Mostrar senha">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<button type="submit">Entrar</button>
</form>
</div>
<script>
(function () {
var botao = document.getElementById('botao-ver-senha');
var campo = document.getElementById('senha');
if (!botao || !campo) return;
botao.addEventListener('click', function () {
var mostrando = campo.type === 'text';
campo.type = mostrando ? 'password' : 'text';
botao.setAttribute('aria-label', mostrando ? 'Mostrar senha' : 'Ocultar senha');
});
})();
</script>
</body>
</html>
@@ -0,0 +1,35 @@
<!-- app/templates/minha_conta.html -->
{% extends "base.html" %}
{% block titulo %}Minha conta{% endblock %}
{% block conteudo %}
<h1>Minha conta</h1>
<div class="cartao">
<p><strong>Nome:</strong> {{ usuario_atual.nome }}</p>
<p><strong>Login:</strong> {{ usuario_atual.login }}</p>
</div>
{% if erro %}<div class="mensagem danger">{{ erro }}</div>{% endif %}
{% if mensagem %}<div class="mensagem success">{{ mensagem }}</div>{% endif %}
{% if eh_conta_env %}
<div class="mensagem warning">
Esta conta é a conta de administrador configurada por variável de ambiente
(ADMIN_USER/ADMIN_PASSWORD no .env do servidor) — a senha se troca lá,
não existe uma linha correspondente na tabela de funcionários.
</div>
{% else %}
<h2>Trocar senha</h2>
<div class="cartao">
<form method="post" action="/minha-conta/senha">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="senha_atual">Senha atual</label>
<input type="password" id="senha_atual" name="senha_atual" required>
<label for="nova_senha">Nova senha</label>
<input type="password" id="nova_senha" name="nova_senha" required>
<label for="confirmar_nova_senha">Confirmar nova senha</label>
<input type="password" id="confirmar_nova_senha" name="confirmar_nova_senha" required>
<button type="submit">Salvar nova senha</button>
</form>
</div>
{% endif %}
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
<!-- app/templates/sso_fiscal.html — entrega a asserção ao Fiscal por POST (nunca na URL) -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Abrindo o GTI Fiscal…</title>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #121214; color: #f4f4f5; }
p { color: #9a9aa2; }
button { padding: 10px 16px; border-radius: 8px; border: 0; background: #F5821F; color: #17171a; font-weight: 700; cursor: pointer; }
</style>
</head>
<body>
<form method="post" action="{{ acao }}" id="sso-fiscal">
<input type="hidden" name="assertion" value="{{ assercao }}">
<input type="hidden" name="next" value="{{ proximo }}">
<p>Abrindo o GTI Fiscal…</p>
<noscript><button type="submit">Continuar</button></noscript>
</form>
<script>document.getElementById("sso-fiscal").submit();</script>
</body>
</html>