Troca botao Abrir por Enviar notificacao na listagem de assembleias

This commit is contained in:
Tiago
2026-10-09 15:33:18 -03:00
parent 4a23f2ff31
commit 70af9dbf7d
4 changed files with 62 additions and 5 deletions
@@ -84,6 +84,7 @@ public final class ClienteHandlers implements HttpHandler {
if(p.length==4&&"condominios".equals(p[0])&&"espacos-comuns".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarEspaco(ex,c,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"assembleias".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarAssembleia(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM assembleias WHERE condominio_id=? ORDER BY data_hora DESC",cond,"assembleias"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"assembleias".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarAssembleia(ex,c,empresaId,cond,id(p[3]),funcionarioId); return; }
if(p.length==5&&"condominios".equals(p[0])&&"assembleias".equals(p[2])&&"notificar".equals(p[4])&&"POST".equals(method)){ long cond=id(p[1]); notificarAssembleia(ex,c,empresaId,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"documentos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) uploadDocumento(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM documentos WHERE condominio_id=? ORDER BY criado_em DESC",cond,"documentos"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==3&&"documentos".equals(p[0])&&"download".equals(p[2])&&"GET".equals(method)){ downloadDocumento(ex,c,id(p[1])); return; }
HttpResponses.json(ex,404,Map.of("erro","endpoint_nao_encontrado"));
@@ -201,6 +202,61 @@ public final class ClienteHandlers implements HttpHandler {
long aviso=criarAvisoAutomaticoAssembleia(c,cond,aid,fid,b,dests,"Assembleia cancelada","A assembleia foi cancelada.",null);
try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET tipo='cancelamento', prioridade='alta' WHERE id=?")){ps.setLong(1,aviso);ps.executeUpdate();}
}
private void notificarAssembleia(HttpExchange ex,Connection c,long emp,long cond,long aid,long fid)throws Exception{
Map<String,Object> ass=one(c,"SELECT * FROM assembleias WHERE id=? AND condominio_id=?",Arrays.asList(aid,cond));
if(ass==null){
HttpResponses.json(ex,404,Map.of("erro","assembleia_nao_encontrada","mensagem","Assembleia não encontrada."));
return;
}
if(!funcionalidadeAtiva(emp,"whatsapp_envio")){
HttpResponses.json(ex,400,Map.of("erro","funcionalidade_whatsapp_inativa","mensagem","Envio por WhatsApp não está liberado para esta empresa no painel Master."));
return;
}
if(evolutionAprovada(emp)==null){
HttpResponses.json(ex,400,Map.of("erro","instancia_pendente_aprovacao","mensagem","A conexão WhatsApp não está configurada ou ainda não foi aprovada pelo painel Master."));
return;
}
List<Destinatario> padrao=destinatariosPadraoAssembleia(c,emp,cond);
if(padrao.isEmpty()){
HttpResponses.json(ex,400,Map.of("erro","sem_destinatarios","mensagem","Nenhum contato ou funcionário vinculado para receber a notificação deste condomínio."));
return;
}
Map<String,Object> aviso=one(c,"SELECT id, status_envio FROM avisos WHERE assembleia_id=? ORDER BY id ASC LIMIT 1",Arrays.asList(aid));
long avisoId;
if(aviso==null){
Object dhObj=ass.get("data_hora");
Timestamp dataHora=dhObj instanceof Timestamp?(Timestamp)dhObj:(dhObj instanceof LocalDateTime?Timestamp.valueOf((LocalDateTime)dhObj):ts(str(dhObj).split("\\.")[0]));
String dataStr=dataHora.toLocalDateTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
Map<String,String> b=new HashMap<>();
b.put("data_hora",dataStr);
b.put("local",str(ass.get("local")));
b.put("pauta",str(ass.get("pauta")));
avisoId=criarAvisoAutomaticoAssembleia(c,cond,aid,fid,b,padrao,"Assembleia agendada","Nova assembleia agendada.",null);
criarAvisoAutomaticoAssembleia(c,cond,aid,fid,b,padrao,"Lembrete de assembleia","Lembrete: a assembleia acontece amanhã.",Timestamp.valueOf(dataHora.toLocalDateTime().minusDays(1)));
criarAvisoAutomaticoAssembleia(c,cond,aid,fid,b,padrao,"Lembrete de assembleia","Lembrete: a assembleia acontece hoje.",dataHora);
} else {
avisoId=((Number)aviso.get("id")).longValue();
List<Map<String,Object>> destsExistentes=rows(c,"SELECT destinatario_id FROM aviso_destinatarios WHERE aviso_id=?",Arrays.asList(avisoId));
if(destsExistentes.isEmpty()){
for(Destinatario d:padrao){
insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(avisoId,d.tipo(),d.id()));
}
}
try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='nao_enviado', enviar_whatsapp=true WHERE id=? AND condominio_id=?")){
ps.setLong(1,avisoId);
ps.setLong(2,cond);
ps.executeUpdate();
}
}
enviarAviso(c,emp,cond,avisoId,false);
Map<String,Object> atualizado=one(c,"SELECT status_envio FROM avisos WHERE id=?",Arrays.asList(avisoId));
String st=atualizado!=null?str(atualizado.get("status_envio")):"enviado";
if("falhou".equals(st)){
HttpResponses.json(ex,200,Map.of("ok",false,"status_envio","falhou","mensagem","Tentativa de envio realizada, porém com falhas no envio. Verifique o status da conexão WhatsApp e os telefones cadastrados."));
} else {
HttpResponses.json(ex,200,Map.of("ok",true,"status_envio",st,"mensagem","Notificação enviada com sucesso!"));
}
}
private void uploadDocumento(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{Multipart mp=parseMultipart(ex); if(mp.bytes.length>MAX_UPLOAD){HttpResponses.json(ex,400,Map.of("erro","arquivo_muito_grande"));return;} String ext=ext(mp.filename); if(!EXT_OK.contains(ext)){HttpResponses.json(ex,400,Map.of("erro","extensao_nao_permitida"));return;} String db=databaseName(emp); Path dir=uploadRoot.resolve(db).resolve(String.valueOf(cond)); Files.createDirectories(dir); String safe=UUID.randomUUID()+"-"+mp.filename.replaceAll("[^a-zA-Z0-9._-]","_"); Path file=dir.resolve(safe); Files.write(file,mp.bytes); String rel=db+"/"+cond+"/"+safe; long id=insert(c,"INSERT INTO documentos (condominio_id,tipo,nome_arquivo,caminho_armazenamento,enviado_por) VALUES (?,?,?,?,?)",Arrays.asList(cond,nullable(mp.tipo),mp.filename,rel,fid)); audit(c,fid,"create","documentos",id,null,Map.of("nome_arquivo",mp.filename)); HttpResponses.json(ex,201,Map.of("documento",one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id))));}
private void downloadDocumento(HttpExchange ex,Connection c,long id)throws Exception{Map<String,Object>d=one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id)); if(d==null){HttpResponses.json(ex,404,Map.of("erro","documento_nao_encontrado"));return;} Path f=uploadRoot.resolve(String.valueOf(d.get("caminho_armazenamento"))); if(!Files.exists(f)){HttpResponses.json(ex,404,Map.of("erro","arquivo_nao_encontrado"));return;} ex.getResponseHeaders().set("Content-Disposition","attachment; filename=\""+d.get("nome_arquivo")+"\""); ex.sendResponseHeaders(200,Files.size(f)); Files.copy(f,ex.getResponseBody()); ex.close();}
@@ -396,7 +452,7 @@ public final class ClienteHandlers implements HttpHandler {
private void detalheAviso(HttpExchange ex,Connection c,long cond,long aviso)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}Map<String,Object> resp=new LinkedHashMap<>();resp.put("aviso",a);resp.put("documentos",rows(c,"SELECT ad.*, d.nome_arquivo, d.caminho_armazenamento FROM aviso_documentos ad JOIN documentos d ON d.id=ad.documento_id WHERE ad.aviso_id=?",Arrays.asList(aviso)));resp.put("destinatarios",rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso)));HttpResponses.json(ex,200,resp);}
private void reenviarAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso)throws Exception{try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='nao_enviado' WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);if(ps.executeUpdate()==0){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}}enviarAviso(c,emp,cond,aviso,false);detalheAviso(ex,c,cond,aviso);}
private void excluirAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(old==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_documentos WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_destinatarios WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM avisos WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);ps.executeUpdate();}audit(c,fid,"delete","avisos",aviso,old,Map.of("ok",true));c.commit();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("DELETE FROM logs_envio_avisos WHERE empresa_id=? AND condominio_id=? AND aviso_id=?")){ps.setLong(1,emp);ps.setLong(2,cond);ps.setLong(3,aviso);ps.executeUpdate();}HttpResponses.json(ex,200,Map.of("ok",true));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void enviarAviso(Connection c,long emp,long cond,long aviso,boolean apenasFalhos)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null)return;boolean w=Boolean.TRUE.equals(a.get("enviar_whatsapp"));boolean e=Boolean.TRUE.equals(a.get("enviar_email"));Map<String,Object> inst=w?evolutionAprovada(emp):null;Map<String,String> smtp=e?smtpConfig():Map.of();List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=?",Arrays.asList(aviso));boolean allOk=true;int tentativas=0;if(dests.isEmpty())allOk=false;for(Map<String,Object>d:dests){String tipo=String.valueOf(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> contato="funcionario".equals(tipo)?funcionario(emp,did):("extra".equals(tipo)?one(c,"SELECT id,nome,telefone,email FROM destinatarios_extra WHERE id=? AND status='ativo'",Arrays.asList(did)):("assembleia".equals(tipo)?one(c,"SELECT id,nome,telefone,email FROM assembleia_responsaveis WHERE id=? AND status='ativo'",Arrays.asList(did)):one(c,"SELECT id,nome,telefone,email FROM contatos WHERE id=?",Arrays.asList(did))));if(contato==null){allOk=false;continue;}String nome=String.valueOf(contato.get("nome"));if(w&&deveTentar(emp,aviso,did,"whatsapp",apenasFalhos)){tentativas++;String tel=telefoneWhatsapp(contato.get("telefone"));try{if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,msg(a));logEnvio(emp,cond,aviso,did,nome,"whatsapp","enviado",limite(resposta));}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"whatsapp","falhou",err.getMessage());}}if(e&&deveTentar(emp,aviso,did,"email",apenasFalhos)){tentativas++;String email=str(contato.get("email"));try{if(!email.contains("@"))throw new IllegalArgumentException("email_invalido");emailClient.enviar(smtp,email,str(a.get("titulo")),msg(a));logEnvio(emp,cond,aviso,did,nome,"email","enviado",null);}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"email","falhou",err.getMessage());}}}if(allOk&&tentativas>0&&reagendarAvisoRecorrente(c,aviso,a))return;try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio=?, ultimo_envio=CASE WHEN ? THEN now() ELSE ultimo_envio END WHERE id=?")){boolean ok=allOk&&tentativas>0;ps.setString(1,ok?"enviado":"falhou");ps.setBoolean(2,ok);ps.setLong(3,aviso);ps.executeUpdate();}}
private void enviarAviso(Connection c,long emp,long cond,long aviso,boolean apenasFalhos)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null)return;boolean w=Boolean.TRUE.equals(a.get("enviar_whatsapp"));boolean e=Boolean.TRUE.equals(a.get("enviar_email"));Map<String,Object> inst=w?evolutionAprovada(emp):null;Map<String,String> smtp=e?smtpConfig():Map.of();List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=?",Arrays.asList(aviso));boolean allOk=true;int tentativas=0;if(dests.isEmpty())allOk=false;for(Map<String,Object>d:dests){String tipo=String.valueOf(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> contato="funcionario".equals(tipo)?funcionario(emp,did):("extra".equals(tipo)?one(c,"SELECT id,nome,telefone,email FROM destinatarios_extra WHERE id=? AND status='ativo'",Arrays.asList(did)):("assembleia".equals(tipo)?one(c,"SELECT id,nome,telefone,email FROM assembleia_responsaveis WHERE id=? AND status='ativo'",Arrays.asList(did)):one(c,"SELECT id,nome,telefone,email FROM contatos WHERE id=?",Arrays.asList(did))));if(contato==null){allOk=false;continue;}String nome=String.valueOf(contato.get("nome"));if(w&&deveTentar(emp,aviso,did,"whatsapp",apenasFalhos)){tentativas++;String tel=telefoneWhatsapp(contato.get("telefone"));try{if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");if(inst==null)throw new IllegalArgumentException("instancia_whatsapp_nao_encontrada");String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,msg(a));logEnvio(emp,cond,aviso,did,nome,"whatsapp","enviado",limite(resposta));}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"whatsapp","falhou",err.getMessage());}}if(e&&deveTentar(emp,aviso,did,"email",apenasFalhos)){tentativas++;String email=str(contato.get("email"));try{if(!email.contains("@"))throw new IllegalArgumentException("email_invalido");emailClient.enviar(smtp,email,str(a.get("titulo")),msg(a));logEnvio(emp,cond,aviso,did,nome,"email","enviado",null);}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"email","falhou",err.getMessage());}}}if(allOk&&tentativas>0&&reagendarAvisoRecorrente(c,aviso,a))return;try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio=?, ultimo_envio=CASE WHEN ? THEN now() ELSE ultimo_envio END WHERE id=?")){boolean ok=allOk&&tentativas>0;ps.setString(1,ok?"enviado":"falhou");ps.setBoolean(2,ok);ps.setLong(3,aviso);ps.executeUpdate();}}
private boolean reagendarAvisoRecorrente(Connection c,long aviso,Map<String,Object>a)throws SQLException{
int meses=a.get("recorrencia_meses")==null?0:((Number)a.get("recorrencia_meses")).intValue();
if(!Boolean.TRUE.equals(a.get("recorrencia_ativa"))||meses<=0)return false;
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Assembleia</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>Assembleia</h1></div><section class="panel"><div class="section-head"><h2>Todas as assembleias</h2><div class="section-actions"><button class="search-icon-btn" type="button" onclick="alternarBuscaAssembleias()" aria-label="Buscar assembleias" title="Buscar"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m16.5 16.5 4 4"></path></svg></button><button class="icon-btn calendar-open" type="button" onclick="abrirCalendarioAssembleias()" title="Calendário de assembleias" aria-label="Calendário de assembleias"></button></div></div><div id="assembleia-search" class="search-strip hidden"><input id="assembleia-search-input" placeholder="Buscar por condominio, tipo, data, local ou status"></div><div class="table-wrap"><table><thead><tr><th>Condominio</th><th>Tipo</th><th>Data</th><th>Local</th><th>Status</th><th>Notificados</th><th></th></tr></thead><tbody id="rows"></tbody></table></div></section><section class="panel"><div class="section-head"><h2>Responsáveis pela assembleia</h2><div class="section-actions"><button class="btn" type="button" onclick="abrirNovoResponsavel()">Novo</button></div></div><div class="table-wrap"><table><thead><tr><th>Nome</th><th>WhatsApp</th><th>E-mail</th><th>Dias disponíveis</th><th>Status</th><th>Ações</th></tr></thead><tbody id="responsaveis-rows"></tbody></table></div></section><div id="responsavel-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharResponsavelModal()"></div><section class="modal-panel"><div class="modal-head"><h2 id="responsavel-modal-title">Responsável</h2><button class="btn secondary" type="button" onclick="fecharResponsavelModal()">Fechar</button></div><div id="responsavel-modal-body"></div></section></div></main><script>Layout.mount('empresa','assembleias');</script><script src="/cliente/assembleias.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>Assembleia</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>Assembleia</h1></div><section class="panel"><div class="section-head"><h2>Todas as assembleias</h2><div class="section-actions"><button class="search-icon-btn" type="button" onclick="alternarBuscaAssembleias()" aria-label="Buscar assembleias" title="Buscar"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m16.5 16.5 4 4"></path></svg></button><button class="icon-btn calendar-open" type="button" onclick="abrirCalendarioAssembleias()" title="Calendário de assembleias" aria-label="Calendário de assembleias"></button></div></div><div id="assembleia-search" class="search-strip hidden"><input id="assembleia-search-input" placeholder="Buscar por condominio, tipo, data, local ou status"></div><div class="table-wrap"><table><thead><tr><th>Condominio</th><th>Tipo</th><th>Data</th><th>Local</th><th>Status</th><th>Notificados</th><th>Ações</th></tr></thead><tbody id="rows"></tbody></table></div></section><section class="panel"><div class="section-head"><h2>Responsáveis pela assembleia</h2><div class="section-actions"><button class="btn" type="button" onclick="abrirNovoResponsavel()">Novo</button></div></div><div class="table-wrap"><table><thead><tr><th>Nome</th><th>WhatsApp</th><th>E-mail</th><th>Dias disponíveis</th><th>Status</th><th>Ações</th></tr></thead><tbody id="responsaveis-rows"></tbody></table></div></section><div id="responsavel-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharResponsavelModal()"></div><section class="modal-panel"><div class="modal-head"><h2 id="responsavel-modal-title">Responsável</h2><button class="btn secondary" type="button" onclick="fecharResponsavelModal()">Fechar</button></div><div id="responsavel-modal-body"></div></section></div></main><script>Layout.mount('empresa','assembleias');</script><script src="/cliente/assembleias.js"></script></body></html>
+3 -2
View File
@@ -18,7 +18,7 @@ function formHtml(r={}){
function renderAssembleias(){
const q=termo();
const lista=assembleias.filter(a=>contem(q,a.condominio_nome,a.tipo,a.data_hora,a.local,a.status,destinatariosLabel(a)));
rows.innerHTML=lista.map(a=>`<tr><td><strong>${esc(a.condominio_nome||'-')}</strong></td><td>${esc(a.tipo)}</td><td>${Util.date(a.data_hora)}</td><td>${esc(a.local||'')}</td><td>${Util.badge(a.status)}</td><td>${esc(destinatariosLabel(a))}</td><td><a class="btn secondary" href="/cliente/condominio-detalhe.html?id=${a.condominio_id}">Abrir</a></td></tr>`).join('')||'<tr><td colspan="7">Nenhuma assembleia encontrada.</td></tr>';
rows.innerHTML=lista.map(a=>`<tr><td><strong>${esc(a.condominio_nome||'-')}</strong></td><td>${esc(a.tipo)}</td><td>${Util.date(a.data_hora)}</td><td>${esc(a.local||'')}</td><td>${Util.badge(a.status)}</td><td>${esc(destinatariosLabel(a))}</td><td><button class="btn secondary" type="button" onclick="enviarNotificacaoAssembleia(this,${a.condominio_id},${a.id})">Enviar notificação</button></td></tr>`).join('')||'<tr><td colspan="7">Nenhuma assembleia encontrada.</td></tr>';
}
function ensureCalendarModal(){let m=document.querySelector('#assembleia-calendar-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','<div id="assembleia-calendar-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharCalendarioAssembleias()"></div><section class="modal-panel calendar-modal"><div class="modal-head"><h2>Calendário de assembleias</h2><button class="btn secondary" type="button" onclick="fecharCalendarioAssembleias()">Fechar</button></div><div class="calendar-layout"><div><div class="calendar-toolbar"><button class="btn secondary" type="button" onclick="mudarMesCalendario(-1)">Anterior</button><strong id="calendar-title"></strong><button class="btn secondary" type="button" onclick="mudarMesCalendario(1)">Próximo</button></div><div id="calendar-grid" class="calendar-grid"></div></div><aside class="calendar-side"><h3 id="calendar-day-title">Selecione um dia</h3><div id="calendar-day-list" class="calendar-day-list"><p class="muted">Clique em um dia para ver as assembleias.</p></div></aside></div></section></div>');return document.querySelector('#assembleia-calendar-modal')}
function renderCalendarioAssembleias(selected){ensureCalendarModal();const title=document.querySelector('#calendar-title'),grid=document.querySelector('#calendar-grid');title.textContent=monthLabel(calMonth);const y=calMonth.getFullYear(),m=calMonth.getMonth(),start=new Date(y,m,1-new Date(y,m,1).getDay()),todayKey=dateKey(new Date());let html=['Dom','Seg','Ter','Qua','Qui','Sex','Sáb'].map(x=>'<div class="calendar-weekday">'+x+'</div>').join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(start.getDate()+i);const key=dateKey(d),items=assembleias.filter(a=>dateKey(a.data_hora)===key),classes=['calendar-day'];if(d.getMonth()!==m)classes.push('other');if(items.length)classes.push('busy');if(key===todayKey)classes.push('today');html+='<button type="button" class="'+classes.join(' ')+'" data-date="'+key+'"><span>'+d.getDate()+'</span>'+(items.length?'<b>'+items.length+'</b>':'')+'</button>'}grid.innerHTML=html;grid.querySelectorAll('.calendar-day').forEach(btn=>btn.onclick=()=>selecionarDiaCalendario(btn.dataset.date));if(selected)selecionarDiaCalendario(selected,false)}
@@ -68,7 +68,8 @@ window.alternarBuscaAssembleias=()=>{
window.abrirCalendarioAssembleias=async()=>{try{if(!assembleias.length)await load();calMonth=new Date();ensureCalendarModal().classList.remove('hidden');renderCalendarioAssembleias()}catch(e){Util.toast(e.message,true)}};
window.fecharCalendarioAssembleias=()=>document.querySelector('#assembleia-calendar-modal')?.classList.add('hidden');
window.mudarMesCalendario=delta=>{calMonth=new Date(calMonth.getFullYear(),calMonth.getMonth()+delta,1);renderCalendarioAssembleias()};
window.selecionarDiaCalendario=(key,mark=true)=>{if(mark)document.querySelectorAll('.calendar-day').forEach(x=>x.classList.toggle('selected',x.dataset.date===key));const list=document.querySelector('#calendar-day-list'),title=document.querySelector('#calendar-day-title'),items=assembleias.filter(a=>dateKey(a.data_hora)===key);const [yy,mm,dd]=key.split('-');title.textContent=dd+'/'+mm+'/'+yy;if(!items.length){list.innerHTML='<p class="muted">Nenhuma assembleia agendada neste dia.</p>';return;}list.innerHTML=items.map(a=>'<div class="calendar-event"><strong>'+esc(a.condominio_nome||'-')+'</strong><span>'+Util.date(a.data_hora)+'</span><span>'+esc(a.tipo)+' - '+esc(a.local||'-')+'</span><p>'+esc(destinatariosLabel(a))+'</p><a class="btn secondary" href="/cliente/condominio-detalhe.html?id='+a.condominio_id+'">Abrir condomínio</a></div>').join('')};
window.selecionarDiaCalendario=(key,mark=true)=>{if(mark)document.querySelectorAll('.calendar-day').forEach(x=>x.classList.toggle('selected',x.dataset.date===key));const list=document.querySelector('#calendar-day-list'),title=document.querySelector('#calendar-day-title'),items=assembleias.filter(a=>dateKey(a.data_hora)===key);const [yy,mm,dd]=key.split('-');title.textContent=dd+'/'+mm+'/'+yy;if(!items.length){list.innerHTML='<p class="muted">Nenhuma assembleia agendada neste dia.</p>';return;}list.innerHTML=items.map(a=>'<div class="calendar-event"><strong>'+esc(a.condominio_nome||'-')+'</strong><span>'+Util.date(a.data_hora)+'</span><span>'+esc(a.tipo)+' - '+esc(a.local||'-')+'</span><p>'+esc(destinatariosLabel(a))+'</p><button class="btn secondary" type="button" onclick="enviarNotificacaoAssembleia(this,'+a.condominio_id+','+a.id+')">Enviar notificação</button></div>').join('')};
window.enviarNotificacaoAssembleia=async(btn,condominioId,assembleiaId)=>{if(!await Util.confirm('Deseja enviar a notificação para os contatos e responsáveis desta assembleia?','Enviar notificação'))return;const original=btn?btn.textContent:'';if(btn){btn.disabled=true;btn.textContent='Enviando...';}try{const res=await Api.post(`/api/condominios/${condominioId}/assembleias/${assembleiaId}/notificar`,{});if(res&&res.ok===false){Util.toast(res.mensagem||'Falha ao enviar notificação.',true);}else{Util.toast(res?.mensagem||'Notificação enviada com sucesso!');}await load();}catch(err){Util.toast(err.message,true);}finally{if(btn){btn.disabled=false;btn.textContent=original;}}};
window.abrirNovoResponsavel=()=>abrirResponsavel({});
window.editarResponsavel=id=>{const r=responsaveis.find(x=>Number(x.id)===Number(id));if(r)abrirResponsavel(r)};
window.fecharResponsavelModal=()=>{modal.classList.add('hidden');modalBody.innerHTML=''};
@@ -18,7 +18,7 @@ window.abrirCalendarioAssembleias=async()=>{try{const d=await Api.get('/api/asse
window.fecharCalendarioAssembleias=()=>document.querySelector('#assembleia-calendar-modal')?.classList.add('hidden');
window.mudarMesCalendario=delta=>{calMonth=new Date(calMonth.getFullYear(),calMonth.getMonth()+delta,1);renderCalendarioAssembleias()};
window.selecionarDiaCalendario=(key,mark=true)=>{if(mark)document.querySelectorAll('.calendar-day').forEach(x=>x.classList.toggle('selected',x.dataset.date===key));const list=document.querySelector('#calendar-day-list'),title=document.querySelector('#calendar-day-title'),items=assembleiasCalendario.filter(a=>dateKey(a.data_hora)===key);const [yy,mm,dd]=key.split('-');title.textContent=dd+'/'+mm+'/'+yy;if(!items.length){list.innerHTML='<p class="muted">Nenhuma assembleia agendada neste dia.</p>';return;}list.innerHTML=items.map(a=>{const dest=(a.destinatarios||[]).map(x=>x.nome+' ('+x.tipo+')').join(', ')||'Nenhum destinatário registrado';return '<div class="calendar-event"><strong>'+a.condominio_nome+'</strong><span>'+Util.date(a.data_hora)+'</span><span>'+a.tipo+' - '+(a.local||'-')+'</span><p>'+dest+'</p><div class="row compact"><button class="btn secondary" type="button" onclick="reenviarAvisoAssembleia('+a.condominio_id+','+a.id+')">Reenviar notificação</button><button class="btn danger" type="button" onclick="cancelarAssembleiaCalendario('+a.condominio_id+','+a.id+')">Cancelar assembleia</button></div></div>'}).join('')};
window.reenviarAvisoAssembleia=async(cond,aid)=>{try{const a=assembleiasCalendario.find(x=>Number(x.id)===Number(aid));if(!a||!a.aviso_id)throw new Error('Aviso da assembleia não encontrado.');await Api.post('/api/condominios/'+cond+'/avisos/'+a.aviso_id+'/reenviar',{});Util.toast('Notificação reenviada')}catch(e){Util.toast(e.message,true)}};
window.reenviarAvisoAssembleia=async(cond,aid)=>{try{const res=await Api.post('/api/condominios/'+cond+'/assembleias/'+aid+'/notificar',{});if(res&&res.ok===false){Util.toast(res.mensagem||'Falha ao enviar notificação.',true);}else{Util.toast(res?.mensagem||'Notificação enviada com sucesso!');}await abrirCalendarioAssembleias();await loadAssembleias();await load();}catch(e){Util.toast(e.message,true)}};
window.cancelarAssembleiaCalendario=async(cond,aid)=>{if(!await Util.confirm('Cancelar esta assembleia?','Cancelar assembleia'))return;try{await Api.put('/api/condominios/'+cond+'/assembleias/'+aid,{status:'cancelada'});Util.toast('Assembleia cancelada');await abrirCalendarioAssembleias();await loadAssembleias();await load()}catch(e){Util.toast(e.message,true)}};
function ensureAvisoNotifModal(){let m=document.querySelector('#aviso-notif-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','<div id="aviso-notif-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharAvisoNotificacao()"></div><section class="modal-panel"><div class="modal-head"><h2>Notificação</h2><button class="btn secondary" type="button" onclick="fecharAvisoNotificacao()">Fechar</button></div><div id="aviso-notif-body"></div><div class="row"><button class="btn" type="button" onclick="salvarAvisoNotificacao()">Salvar</button><button class="btn secondary" type="button" onclick="fecharAvisoNotificacao()">Cancelar</button></div></section></div>');return document.querySelector('#aviso-notif-modal')}
function avisoDestInputs(){return [...document.querySelectorAll('#aviso-dests-hidden input[name=dest]')]}