feat(master): unificar planos em um unico bloco com modal de novo plano e icones de editar/excluir

This commit is contained in:
Tiago
2026-10-10 17:32:02 -03:00
parent bff0edf8cb
commit eeae68ec70
3 changed files with 215 additions and 6 deletions
@@ -131,9 +131,10 @@ public final class MasterHandlers implements HttpHandler {
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 2 && "planos".equals(parts[0]) && "PUT".equals(method)) {
atualizarPlano(ex, parseId(parts[1]));
return;
if (parts.length == 2 && "planos".equals(parts[0])) {
if ("PUT".equals(method)) { atualizarPlano(ex, parseId(parts[1])); return; }
else if ("DELETE".equals(method)) { excluirPlano(ex, parseId(parts[1])); return; }
else { HttpResponses.methodNotAllowed(ex); return; }
}
if (parts.length == 1 && "logs-envio".equals(parts[0]) && "GET".equals(method)) {
listarLogsEnvio(ex);
@@ -526,6 +527,28 @@ public final class MasterHandlers implements HttpHandler {
private void criarPlano(HttpExchange ex) throws Exception { salvarPlano(ex, null); }
private void atualizarPlano(HttpExchange ex, long id) throws Exception { salvarPlano(ex, id); }
private void excluirPlano(HttpExchange ex, long id) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
if (exists(conn, "SELECT 1 FROM assinaturas WHERE plano_id = ?", id)) {
try (PreparedStatement ps = conn.prepareStatement("UPDATE planos SET ativo=false WHERE id=?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
} else {
try (PreparedStatement ps = conn.prepareStatement("DELETE FROM plano_funcionalidades WHERE plano_id=?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
try (PreparedStatement ps = conn.prepareStatement("DELETE FROM planos WHERE id=?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
conn.commit();
HttpResponses.json(ex, 200, Map.of("sucesso", true));
}
}
private void salvarPlano(HttpExchange ex, Long id) throws Exception {
String raw = rawBody(ex);
@@ -669,7 +692,7 @@ public final class MasterHandlers implements HttpHandler {
private int diaVencimento(Connection conn,long empresaId)throws SQLException{Map<String,Object>a=one(conn,"SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",List.of(empresaId));if(a==null||a.get("dia_vencimento")==null)return 7;int dia=((Number)a.get("dia_vencimento")).intValue();return Math.max(1,Math.min(28,dia));}
private long inserirPlano(Connection c, Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO planos (nome,valor_mensal_centavos,descricao,ativo) VALUES (?,?,?,true) RETURNING id")){ps.setString(1,required(b,"nome"));ps.setLong(2,Long.parseLong(required(b,"valor_mensal_centavos")));ps.setString(3,b.get("descricao"));try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private long atualizarPlanoRow(Connection c,long id,Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("UPDATE planos SET nome=COALESCE(?,nome), valor_mensal_centavos=COALESCE(?,valor_mensal_centavos), descricao=COALESCE(?,descricao) WHERE id=?")){ps.setString(1,b.get("nome")); if(b.get("valor_mensal_centavos")==null)ps.setNull(2,Types.BIGINT);else ps.setLong(2,Long.parseLong(b.get("valor_mensal_centavos"))); ps.setString(3,b.get("descricao")); ps.setLong(4,id); ps.executeUpdate(); return id;}}
private long atualizarPlanoRow(Connection c,long id,Map<String,String>b)throws SQLException{String sql="UPDATE planos SET nome=COALESCE(?,nome), valor_mensal_centavos=COALESCE(?,valor_mensal_centavos), descricao=COALESCE(?,descricao)"+(b.containsKey("ativo")?", ativo=?":"")+" WHERE id=?";try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,b.get("nome")); if(b.get("valor_mensal_centavos")==null)ps.setNull(2,Types.BIGINT);else ps.setLong(2,Long.parseLong(b.get("valor_mensal_centavos"))); ps.setString(3,b.get("descricao")); int idx=4; if(b.containsKey("ativo")){ps.setBoolean(idx++,Boolean.parseBoolean(b.get("ativo")));} ps.setLong(idx,id); ps.executeUpdate(); return id;}}
private void sincronizarPlanoFuncionalidades(Connection c,long planoId,List<String> chaves)throws SQLException{try(PreparedStatement del=c.prepareStatement("DELETE FROM plano_funcionalidades WHERE plano_id=?")){del.setLong(1,planoId);del.executeUpdate();}try(PreparedStatement ins=c.prepareStatement("INSERT INTO plano_funcionalidades (plano_id,funcionalidade_id) SELECT ?, id FROM funcionalidades WHERE chave=?")){for(String chave:chaves){ins.setLong(1,planoId);ins.setString(2,chave);ins.addBatch();}ins.executeBatch();}}
private void copiarFuncionalidadesPlano(Connection c,long empresaId,long planoId)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO empresa_funcionalidades (empresa_id,funcionalidade_id,ativa) SELECT ?, funcionalidade_id, true FROM plano_funcionalidades WHERE plano_id=? ON CONFLICT (empresa_id,funcionalidade_id) DO UPDATE SET ativa=true")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.executeUpdate();}}
private void salvarAssinatura(Connection c,long empresaId,long planoId)throws SQLException{Map<String,Object> p=one(c,"SELECT valor_mensal_centavos FROM planos WHERE id=?",List.of(planoId));if(p==null)throw new IllegalArgumentException("Plano nao encontrado");try(PreparedStatement ps=c.prepareStatement("UPDATE assinaturas SET status='inativa' WHERE empresa_id=? AND status='ativa'")){ps.setLong(1,empresaId);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("INSERT INTO assinaturas (empresa_id,plano_id,valor_mensal_centavos,dia_vencimento,status) VALUES (?,?,?,?, 'ativa')")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.setLong(3,((Number)p.get("valor_mensal_centavos")).longValue());ps.setInt(4,7);ps.executeUpdate();}}
+83 -1
View File
@@ -1 +1,83 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Planos</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Planos</h1></div><section class="panel"><h2>Novo plano</h2><form id="plano-form" class="row"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>Valor em centavos</label><input name="valor_mensal_centavos" type="number" required></div><div class="field"><label>Descricao</label><input name="descricao"></div><button class="btn">Salvar</button></form></section><section class="panel"><h2>Planos cadastrados</h2><div class="table-wrap"><table><thead><tr><th>ID</th><th>Nome</th><th>Valor</th><th>Descricao</th><th>Status</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>Layout.mount('master','planos');</script><script src="/master/planos.js"></script></body></html>
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Planos</title>
<link rel="stylesheet" href="/css/app.css">
<script src="/js/auth.js"></script>
<script src="/js/util.js"></script>
<script src="/js/api.js"></script>
<script src="/js/layout.js"></script>
</head>
<body>
<main>
<div class="topbar">
<h1>Planos</h1>
</div>
<section class="panel">
<div class="section-head">
<h2>Planos cadastrados</h2>
<div class="section-actions">
<button class="header-add-btn" type="button" onclick="abrirModalPlano()" title="Novo plano" aria-label="Novo plano">
<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Nome</th>
<th>Valor</th>
<th>Descrição</th>
<th>Status</th>
<th style="text-align: right;">Ações</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</section>
<!-- Modal de Plano (Popup) -->
<div id="plano-modal" class="modal hidden">
<div class="modal-backdrop" onclick="fecharModalPlano()"></div>
<section class="modal-panel">
<div class="modal-head">
<h2 id="plano-modal-title">Novo plano</h2>
<button class="btn secondary" type="button" onclick="fecharModalPlano()">Fechar</button>
</div>
<form id="plano-form" class="grid cols">
<div class="field">
<label>Nome do plano</label>
<input name="nome" required placeholder="Ex: Básico, Profissional, Premium">
</div>
<div class="field">
<label>Valor em centavos</label>
<input name="valor_mensal_centavos" type="number" required placeholder="Ex: 19990 (= R$ 199,90)">
</div>
<div class="field">
<label>Descrição</label>
<input name="descricao" placeholder="Descrição do plano">
</div>
<div class="field" id="campo-status-plano" style="display: none;">
<label>Status</label>
<select name="ativo" id="plano-ativo">
<option value="true">Ativo</option>
<option value="false">Inativo</option>
</select>
</div>
<div class="modal-actions" style="grid-column: 1 / -1;">
<button class="btn secondary" type="button" onclick="fecharModalPlano()">Cancelar</button>
<button class="btn" type="submit">Salvar plano</button>
</div>
</form>
</section>
</div>
</main>
<script>Layout.mount('master','planos');</script>
<script src="/master/planos.js"></script>
</body>
</html>
+105 -1
View File
@@ -1 +1,105 @@
const form=document.querySelector('#plano-form'),rows=document.querySelector('#rows');async function load(){try{const d=await Api.get('/api/master/planos');rows.innerHTML=(d.planos||[]).map(p=>`<tr><td>${p.id}</td><td>${p.nome}</td><td>${Util.money(p.valor_mensal_centavos)}</td><td>${p.descricao||''}</td><td>${Util.badge(p.ativo?'ativo':'inativo')}</td></tr>`).join('')}catch(e){Util.toast(e.message,true)}}form.onsubmit=async e=>{e.preventDefault();const b=Util.formData(form);b.funcionalidades=[];try{await Api.post('/api/master/planos',b);form.reset();Util.toast('Plano salvo');load()}catch(err){Util.toast(err.message,true)}};load();
const form = document.querySelector('#plano-form');
const rows = document.querySelector('#rows');
const modal = document.querySelector('#plano-modal');
const modalTitle = document.querySelector('#plano-modal-title');
const campoStatus = document.querySelector('#campo-status-plano');
let planos = [];
let planoEditandoId = null;
const Icons = {
edit: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>',
trash: '<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>'
};
function esc(v){
return String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
}
async function load() {
try {
const d = await Api.get('/api/master/planos');
planos = d.planos || [];
render();
} catch (e) {
Util.toast(e.message, true);
}
}
function render() {
rows.innerHTML = planos.length ? planos.map(p => `<tr>
<td>${p.id}</td>
<td>${esc(p.nome)}</td>
<td>${Util.money(p.valor_mensal_centavos)}</td>
<td>${esc(p.descricao || '-')}</td>
<td>${Util.badge(p.ativo ? 'ativo' : 'inativo')}</td>
<td style="text-align: right;">
<div class="table-actions">
<button class="table-icon-btn" type="button" onclick="editarPlano(${p.id})" title="Editar plano" aria-label="Editar plano">${Icons.edit}</button>
<button class="table-icon-btn danger" type="button" onclick="excluirPlano(${p.id})" title="Excluir plano" aria-label="Excluir plano">${Icons.trash}</button>
</div>
</td>
</tr>`).join('') : '<tr><td colspan="6">Nenhum plano cadastrado.</td></tr>';
}
window.abrirModalPlano = () => {
planoEditandoId = null;
form.reset();
modalTitle.textContent = 'Novo plano';
campoStatus.style.display = 'none';
modal?.classList.remove('hidden');
form.querySelector('input[name="nome"]')?.focus();
};
window.fecharModalPlano = () => {
modal?.classList.add('hidden');
};
window.editarPlano = (id) => {
const p = planos.find(x => Number(x.id) === Number(id));
if (!p) return;
planoEditandoId = p.id;
form.reset();
modalTitle.textContent = 'Editar plano';
form.querySelector('input[name="nome"]').value = p.nome || '';
form.querySelector('input[name="valor_mensal_centavos"]').value = p.valor_mensal_centavos ?? '';
form.querySelector('input[name="descricao"]').value = p.descricao || '';
form.querySelector('#plano-ativo').value = p.ativo ? 'true' : 'false';
campoStatus.style.display = 'block';
modal?.classList.remove('hidden');
form.querySelector('input[name="nome"]')?.focus();
};
window.excluirPlano = async (id) => {
const p = planos.find(x => Number(x.id) === Number(id));
const nome = p ? ` "${p.nome}"` : '';
if (!await Util.confirm(`Tem certeza que deseja excluir o plano${nome}?`, 'Excluir plano')) return;
try {
await Api.request(`/api/master/planos/${id}`, { method: 'DELETE' });
Util.toast('Plano excluído com sucesso');
load();
} catch (err) {
Util.toast(err.message, true);
}
};
form.onsubmit = async (e) => {
e.preventDefault();
const b = Util.formData(form);
b.funcionalidades = [];
try {
if (planoEditandoId) {
await Api.put(`/api/master/planos/${planoEditandoId}`, b);
Util.toast('Plano atualizado com sucesso');
} else {
await Api.post('/api/master/planos', b);
Util.toast('Plano cadastrado com sucesso');
}
form.reset();
fecharModalPlano();
load();
} catch (err) {
Util.toast(err.message, true);
}
};
load();