1051 lines
34 KiB
Go
1051 lines
34 KiB
Go
package elinps
|
|
|
|
import (
|
|
crand "crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"e-li.nps/internal/contratos"
|
|
"e-li.nps/internal/db"
|
|
chi "github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Proteção simples do painel administrativo.
|
|
//
|
|
// Objetivo: bloquear acesso ao painel com uma senha definida no .env.
|
|
// Implementação: cookie simples com token aleatório por boot.
|
|
//
|
|
// Observação: propositalmente simples (sem banco) para manter o projeto leve.
|
|
type AuthPainel struct {
|
|
Senha string
|
|
Token string
|
|
}
|
|
|
|
const (
|
|
cookiePainelToken = "eli_nps_painel"
|
|
cookiePainelOper = "eli_nps_operador"
|
|
cookiePainelSessao = "eli_nps_painel_sessao"
|
|
)
|
|
|
|
// Regex pré-compilada (evita recompilar a cada request).
|
|
var naoDigitosRe = regexp.MustCompile(`\D`)
|
|
|
|
func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
|
|
if !a.habilitado() {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte("painel desabilitado"))
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
senha := r.FormValue("senha")
|
|
operador := strings.TrimSpace(r.FormValue("operador"))
|
|
if subtle.ConstantTimeCompare([]byte(senha), []byte(a.Senha)) != 1 {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte("senha invalida"))
|
|
return
|
|
}
|
|
if len(operador) < 2 || len(operador) > 80 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("operador invalido"))
|
|
return
|
|
}
|
|
|
|
// Sessão do painel (por login) para aplicar regra: só edita/deleta comentário
|
|
// na mesma sessão.
|
|
// Observação: diferente do cookie de autenticação do painel (token por boot).
|
|
sessaoID := gerarSessaoUUID()
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: a.cookieName(),
|
|
Value: a.Token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
// Secure deve ser true em produção com HTTPS.
|
|
Secure: false,
|
|
// Expira em 24h (relogin simples).
|
|
Expires: time.Now().Add(24 * time.Hour),
|
|
})
|
|
|
|
// Operador (auditoria de comentários/ações)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: cookiePainelOper,
|
|
Value: url.PathEscape(operador),
|
|
Path: "/painel",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: false,
|
|
Expires: time.Now().Add(180 * 24 * time.Hour),
|
|
})
|
|
|
|
// Sessão por login
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: cookiePainelSessao,
|
|
Value: sessaoID,
|
|
Path: "/painel",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: false,
|
|
Expires: time.Now().Add(24 * time.Hour),
|
|
})
|
|
|
|
http.Redirect(w, r, "/painel", http.StatusFound)
|
|
}
|
|
|
|
func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) {
|
|
// HTML mínimo para evitar dependências.
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(`<!doctype html>
|
|
<html lang="pt-br">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>e-li.nps • Painel</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;}
|
|
.card{max-width:420px;margin:0 auto;border:1px solid #e5e5e5;border-radius:12px;padding:16px;background:#fff;}
|
|
label{display:block;font-size:12px;color:#444;margin-bottom:6px;}
|
|
input{width:100%;padding:10px;border:1px solid #ddd;border-radius:10px;}
|
|
button{margin-top:12px;width:100%;padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer;}
|
|
.muted{color:#555;font-size:13px;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>e-li.nps • Painel</h1>
|
|
<p class="muted">Acesso protegido por senha (SENHA_PAINEL).</p>
|
|
<form method="POST" action="/painel/login">
|
|
<label>Operador</label>
|
|
<input type="text" name="operador" autocomplete="name" />
|
|
<label>Senha</label>
|
|
<input type="password" name="senha" autocomplete="current-password" />
|
|
<button type="submit">Entrar</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>`))
|
|
}
|
|
|
|
func (a AuthPainel) habilitado() bool { return a.Senha != "" && a.Token != "" }
|
|
func (a AuthPainel) cookieName() string { return cookiePainelToken }
|
|
|
|
func (a AuthPainel) isAutenticado(r *http.Request) bool {
|
|
c, err := r.Cookie(a.cookieName())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(a.Token)) == 1
|
|
}
|
|
|
|
func operadorFromCookie(r *http.Request) string {
|
|
c, err := r.Cookie(cookiePainelOper)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
v, err := url.PathUnescape(c.Value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
func sessaoPainelFromCookie(r *http.Request) string {
|
|
c, err := r.Cookie(cookiePainelSessao)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(c.Value)
|
|
}
|
|
|
|
func gerarSessaoUUID() string {
|
|
// UUID v4 via crypto/rand.
|
|
b := make([]byte, 16)
|
|
_, _ = crand.Read(b)
|
|
// Set version (4) and variant (RFC4122)
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
func (a AuthPainel) middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !a.habilitado() {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte("painel desabilitado"))
|
|
return
|
|
}
|
|
if a.isAutenticado(r) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/painel/login", http.StatusFound)
|
|
})
|
|
}
|
|
|
|
type NPSMensal = contratos.NPSMensal
|
|
type RespostaPainel = contratos.RespostaPainel
|
|
type PainelDados = contratos.PainelDados
|
|
|
|
func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
|
|
// Garante tabelas globais do painel (comentários/status).
|
|
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
|
|
a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao garantir tabelas do painel"})
|
|
return
|
|
}
|
|
|
|
// Query params
|
|
produto := r.URL.Query().Get("produto")
|
|
pagina := 1
|
|
if p := r.URL.Query().Get("pagina"); p != "" {
|
|
// best-effort parse
|
|
_, _ = fmt.Sscanf(p, "%d", &pagina)
|
|
if pagina <= 0 {
|
|
pagina = 1
|
|
}
|
|
}
|
|
somenteBaixas := r.URL.Query().Get("baixas") == "1"
|
|
somentePendentes := r.URL.Query().Get("pendentes") == "1"
|
|
|
|
produtos, err := store.ListarProdutos(ctx)
|
|
if err != nil {
|
|
a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao listar produtos"})
|
|
return
|
|
}
|
|
if produto == "" && len(produtos) > 0 {
|
|
produto = produtos[0]
|
|
}
|
|
|
|
operador := operadorFromCookie(r)
|
|
sessaoID := sessaoPainelFromCookie(r)
|
|
|
|
dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas, SomentePendentes: somentePendentes, Operador: operador, SessaoID: sessaoID}
|
|
if produto == "" {
|
|
a.renderPainelHTML(w, dados)
|
|
return
|
|
}
|
|
|
|
// tabela segura
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
dados.MsgErro = "produto inválido"
|
|
a.renderPainelHTML(w, dados)
|
|
return
|
|
}
|
|
tabela := db.TableNameForProduto(prodNorm)
|
|
if err := db.EnsureNPSTable(ctx, store.poolRef(), tabela); err != nil {
|
|
dados.MsgErro = "erro ao garantir tabela"
|
|
a.renderPainelHTML(w, dados)
|
|
return
|
|
}
|
|
|
|
meses, err := store.NPSMesAMes(ctx, tabela, 12)
|
|
if err != nil {
|
|
dados.MsgErro = "erro ao calcular NPS"
|
|
a.renderPainelHTML(w, dados)
|
|
return
|
|
}
|
|
dados.Meses = meses
|
|
|
|
respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
respostas = []contratos.RespostaPainel{}
|
|
} else {
|
|
dados.MsgErro = "erro ao listar respostas"
|
|
}
|
|
}
|
|
dados.Respostas = respostas
|
|
|
|
// Enriquecimento: status de análise + comentários internos (em lote).
|
|
ids := make([]string, 0, len(respostas))
|
|
for _, rr := range respostas {
|
|
ids = append(ids, rr.ID)
|
|
}
|
|
st, err := store.GetStatusAnaliseBatch(ctx, prodNorm, ids)
|
|
if err == nil {
|
|
dados.StatusAnalise = st
|
|
}
|
|
coms, err := store.ListarComentariosPainelBatch(ctx, prodNorm, ids)
|
|
if err == nil {
|
|
dados.Comentarios = coms
|
|
}
|
|
|
|
// Filtro: somente pendentes (após carregar status em lote).
|
|
if somentePendentes {
|
|
filtradas := make([]contratos.RespostaPainel, 0, len(dados.Respostas))
|
|
for _, rr := range dados.Respostas {
|
|
st := contratos.StatusAnalisePendente
|
|
if dados.StatusAnalise != nil {
|
|
if v, ok := dados.StatusAnalise[rr.ID]; ok {
|
|
st = v
|
|
}
|
|
}
|
|
if st != contratos.StatusAnaliseConcluida {
|
|
filtradas = append(filtradas, rr)
|
|
}
|
|
}
|
|
dados.Respostas = filtradas
|
|
}
|
|
|
|
a.renderPainelHTML(w, dados)
|
|
}
|
|
|
|
func (a AuthPainel) handlerExportCSV(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
|
|
produto := r.URL.Query().Get("produto")
|
|
somenteBaixas := r.URL.Query().Get("baixas") == "1"
|
|
if strings.TrimSpace(produto) == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto obrigatorio"))
|
|
return
|
|
}
|
|
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto invalido"))
|
|
return
|
|
}
|
|
tabela := db.TableNameForProduto(prodNorm)
|
|
if err := db.EnsureNPSTable(ctx, store.poolRef(), tabela); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("erro ao garantir tabela"))
|
|
return
|
|
}
|
|
|
|
rows, err := store.ExportarRespostas(ctx, tabela, ExportarRespostasFiltro{SomenteNotasBaixas: somenteBaixas})
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("erro ao listar respostas"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
agora := time.Now().Format("20060102_150405")
|
|
nome := "respostas_" + prodNorm
|
|
if somenteBaixas {
|
|
nome += "_baixas"
|
|
}
|
|
nome += "_" + agora + ".csv"
|
|
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", nome))
|
|
|
|
// BOM UTF-8 para Excel/PT-BR abrir corretamente.
|
|
_, _ = w.Write([]byte("\xEF\xBB\xBF"))
|
|
|
|
cw := csv.NewWriter(w)
|
|
cw.Comma = ';'
|
|
defer cw.Flush()
|
|
|
|
formatarDataBR := func(t time.Time) string {
|
|
loc, err := time.LoadLocation("America/Sao_Paulo")
|
|
if err != nil {
|
|
loc = time.FixedZone("America/Sao_Paulo", -3*60*60)
|
|
}
|
|
return t.In(loc).Format("02/01/2006 15:04")
|
|
}
|
|
|
|
_ = cw.Write([]string{
|
|
"data",
|
|
"nota",
|
|
"usuario_nome",
|
|
"usuario_codigo",
|
|
"inquilino_nome",
|
|
"inquilino_codigo",
|
|
"email",
|
|
"telefone",
|
|
"comentario",
|
|
})
|
|
|
|
for rows.Next() {
|
|
var rr RespostaPainel
|
|
if err := rows.Scan(
|
|
&rr.ID,
|
|
&rr.RespondidoEm,
|
|
&rr.PedidoCriadoEm,
|
|
&rr.InquilinoCodigo,
|
|
&rr.InquilinoNome,
|
|
&rr.UsuarioCodigo,
|
|
&rr.UsuarioNome,
|
|
&rr.UsuarioEmail,
|
|
&rr.UsuarioTelefone,
|
|
&rr.Nota,
|
|
&rr.Justificativa,
|
|
); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
data := ""
|
|
if rr.RespondidoEm != nil {
|
|
data = formatarDataBR(*rr.RespondidoEm)
|
|
} else {
|
|
data = formatarDataBR(rr.PedidoCriadoEm)
|
|
}
|
|
|
|
nota := ""
|
|
if rr.Nota != nil {
|
|
nota = strconv.Itoa(*rr.Nota)
|
|
}
|
|
|
|
usuarioCod := ""
|
|
if rr.UsuarioCodigo != nil {
|
|
usuarioCod = *rr.UsuarioCodigo
|
|
}
|
|
email := ""
|
|
if rr.UsuarioEmail != nil {
|
|
email = *rr.UsuarioEmail
|
|
}
|
|
tel := ""
|
|
if rr.UsuarioTelefone != nil {
|
|
tel = *rr.UsuarioTelefone
|
|
}
|
|
coment := ""
|
|
if rr.Justificativa != nil {
|
|
coment = *rr.Justificativa
|
|
}
|
|
|
|
_ = cw.Write([]string{
|
|
data,
|
|
nota,
|
|
rr.UsuarioNome,
|
|
usuarioCod,
|
|
rr.InquilinoNome,
|
|
rr.InquilinoCodigo,
|
|
email,
|
|
tel,
|
|
coment,
|
|
})
|
|
|
|
// Flush incremental para streaming.
|
|
cw.Flush()
|
|
if err := cw.Error(); err != nil {
|
|
return
|
|
}
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------
|
|
// Ações do painel (comentários/status)
|
|
// ------------------------------
|
|
|
|
func (a AuthPainel) redirectVoltarPainel(w http.ResponseWriter, r *http.Request, produto string) {
|
|
// Mantém querystring original (pagina/baixas/etc).
|
|
q := r.URL.Query()
|
|
q.Set("produto", produto)
|
|
u := "/painel?" + q.Encode()
|
|
http.Redirect(w, r, u, http.StatusFound)
|
|
}
|
|
|
|
func (a AuthPainel) handlerConcluirResposta(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
|
|
produto := chi.URLParam(r, "produto")
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto invalido"))
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
operador := operadorFromCookie(r)
|
|
if operador == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("operador obrigatorio"))
|
|
return
|
|
}
|
|
|
|
_ = operador // reservado: se no futuro quisermos registrar operador no status
|
|
|
|
if err := store.SetStatusAnalise(ctx, prodNorm, id, StatusAnaliseConcluida); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
a.redirectVoltarPainel(w, r, prodNorm)
|
|
}
|
|
|
|
func (a AuthPainel) handlerReabrirResposta(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
|
|
produto := chi.URLParam(r, "produto")
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto invalido"))
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
operador := operadorFromCookie(r)
|
|
if operador == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("operador obrigatorio"))
|
|
return
|
|
}
|
|
|
|
_ = operador
|
|
if err := store.SetStatusAnalise(ctx, prodNorm, id, StatusAnalisePendente); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
a.redirectVoltarPainel(w, r, prodNorm)
|
|
}
|
|
|
|
func (a AuthPainel) handlerCriarComentario(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
|
|
produto := chi.URLParam(r, "produto")
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto invalido"))
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
operador := operadorFromCookie(r)
|
|
sessaoID := sessaoPainelFromCookie(r)
|
|
if operador == "" || sessaoID == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("operador/sessao obrigatorios"))
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
comentario := strings.TrimSpace(r.FormValue("comentario"))
|
|
if comentario == "" || len(comentario) > 2000 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("comentario invalido"))
|
|
return
|
|
}
|
|
|
|
if err := store.CriarComentarioPainel(ctx, prodNorm, id, operador, sessaoID, comentario); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
a.redirectVoltarPainel(w, r, prodNorm)
|
|
}
|
|
|
|
func (a AuthPainel) handlerEditarComentario(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
|
|
produto := chi.URLParam(r, "produto")
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto invalido"))
|
|
return
|
|
}
|
|
_ = chi.URLParam(r, "id")
|
|
comentarioID := chi.URLParam(r, "comentarioID")
|
|
sessaoID := sessaoPainelFromCookie(r)
|
|
if sessaoID == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("sessao obrigatoria"))
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
comentario := strings.TrimSpace(r.FormValue("comentario"))
|
|
if comentario == "" || len(comentario) > 2000 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("comentario invalido"))
|
|
return
|
|
}
|
|
|
|
ok, err := store.EditarComentarioPainel(ctx, comentarioID, sessaoID, comentario)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
if !ok {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte("nao permitido"))
|
|
return
|
|
}
|
|
a.redirectVoltarPainel(w, r, prodNorm)
|
|
}
|
|
|
|
func (a AuthPainel) handlerDeletarComentario(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
|
|
produto := chi.URLParam(r, "produto")
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto invalido"))
|
|
return
|
|
}
|
|
_ = chi.URLParam(r, "id")
|
|
comentarioID := chi.URLParam(r, "comentarioID")
|
|
sessaoID := sessaoPainelFromCookie(r)
|
|
if sessaoID == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("sessao obrigatoria"))
|
|
return
|
|
}
|
|
|
|
ok, err := store.DeletarComentarioPainel(ctx, comentarioID, sessaoID)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
if !ok {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte("nao permitido"))
|
|
return
|
|
}
|
|
a.redirectVoltarPainel(w, r, prodNorm)
|
|
}
|
|
|
|
// handlerComentariosModal renderiza um modal (HTML completo) para gestão de comentários
|
|
// de uma resposta específica.
|
|
//
|
|
// Motivação: manter o painel sem JS e ainda permitir uma UI melhor para comentários.
|
|
func (a AuthPainel) handlerComentariosModal(w http.ResponseWriter, r *http.Request, store *Store) {
|
|
ctx := r.Context()
|
|
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
|
|
produto := chi.URLParam(r, "produto")
|
|
prodNorm, err := db.NormalizeProduto(produto)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("produto invalido"))
|
|
return
|
|
}
|
|
respostaID := chi.URLParam(r, "id")
|
|
|
|
comentarios, err := store.ListarComentariosPainel(ctx, prodNorm, respostaID)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("db"))
|
|
return
|
|
}
|
|
|
|
operador := operadorFromCookie(r)
|
|
sessaoID := sessaoPainelFromCookie(r)
|
|
|
|
// Para voltar ao painel mantendo filtros.
|
|
back := "/painel?" + r.URL.Query().Encode()
|
|
if !strings.Contains(back, "produto=") {
|
|
if strings.Contains(back, "?") {
|
|
back += "&produto=" + url.QueryEscape(prodNorm)
|
|
} else {
|
|
back = "/painel?produto=" + url.QueryEscape(prodNorm)
|
|
}
|
|
}
|
|
|
|
formatarDataPainel := func(t time.Time) string {
|
|
loc, err := time.LoadLocation("America/Sao_Paulo")
|
|
if err != nil {
|
|
loc = time.FixedZone("America/Sao_Paulo", -3*60*60)
|
|
}
|
|
return t.In(loc).Format("02/01/2006 15:04")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
var b strings.Builder
|
|
b.WriteString("<!doctype html><html lang=\"pt-br\"><head><meta charset=\"utf-8\"/>")
|
|
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>")
|
|
b.WriteString("<title>Comentários • Painel</title>")
|
|
b.WriteString(`<style>
|
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:rgba(0,0,0,.35);margin:0;padding:18px;}
|
|
.modal{max-width:860px;margin:0 auto;background:#fff;border-radius:14px;border:1px solid #e5e5e5;box-shadow:0 12px 30px rgba(0,0,0,.2);padding:14px;}
|
|
.top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;}
|
|
.badge{display:inline-block;padding:6px 10px;border-radius:999px;background:#f2f2f2;border:1px solid #e5e5e5;font-size:12px;}
|
|
.muted{color:#666;font-size:12px;}
|
|
textarea{width:100%;min-height:90px;border:1px solid #ddd;border-radius:12px;padding:10px;font-size:14px;}
|
|
button{cursor:pointer;}
|
|
.btn{padding:8px 12px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;}
|
|
.btn2{padding:8px 12px;border-radius:10px;border:1px solid #ddd;background:#fff;color:#111;}
|
|
.card{border:1px solid #eee;border-radius:12px;padding:10px;margin-top:10px;}
|
|
</style>`)
|
|
b.WriteString("</head><body>")
|
|
b.WriteString("<div class=\"modal\">")
|
|
b.WriteString("<div class=\"top\">")
|
|
b.WriteString("<div><h2 style=\"margin:0\">Comentários internos</h2>")
|
|
b.WriteString("<div class=\"muted\">Produto: <b>" + template.HTMLEscapeString(prodNorm) + "</b> • Resposta: <b>" + template.HTMLEscapeString(respostaID) + "</b></div>")
|
|
if operador != "" {
|
|
b.WriteString("<div class=\"muted\">Operador: <b>" + template.HTMLEscapeString(operador) + "</b></div>")
|
|
}
|
|
b.WriteString("</div>")
|
|
b.WriteString("<a class=\"btn2\" href=\"" + template.HTMLEscapeString(back) + "\">Fechar</a>")
|
|
b.WriteString("</div>")
|
|
|
|
// Lista
|
|
for _, c := range comentarios {
|
|
b.WriteString("<div class=\"card\">")
|
|
b.WriteString("<div class=\"muted\" style=\"margin-bottom:8px\">" + template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + "</div>")
|
|
b.WriteString("<div style=\"white-space:pre-wrap\">" + template.HTMLEscapeString(c.Comentario) + "</div>")
|
|
if strings.TrimSpace(sessaoID) != "" && c.SessaoID == sessaoID {
|
|
// Editar
|
|
b.WriteString("<details style=\"margin-top:10px\"><summary class=\"muted\">Editar</summary>")
|
|
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios/" + url.PathEscape(c.ID) + "/editar?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
|
|
b.WriteString("<textarea name=\"comentario\">" + template.HTMLEscapeString(c.Comentario) + "</textarea>")
|
|
b.WriteString("<div style=\"display:flex;gap:8px;margin-top:8px\"><button class=\"btn\" type=\"submit\">Salvar</button></div>")
|
|
b.WriteString("</form></details>")
|
|
// Deletar
|
|
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios/" + url.PathEscape(c.ID) + "/deletar?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
|
|
b.WriteString("<button class=\"btn2\" type=\"submit\">Excluir</button>")
|
|
b.WriteString("</form>")
|
|
}
|
|
b.WriteString("</div>")
|
|
}
|
|
|
|
// Criar
|
|
if strings.TrimSpace(operador) != "" && strings.TrimSpace(sessaoID) != "" {
|
|
b.WriteString("<div class=\"card\">")
|
|
b.WriteString("<div class=\"muted\">Novo comentário</div>")
|
|
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
|
|
b.WriteString("<textarea name=\"comentario\" placeholder=\"Registrar ação/observação...\"></textarea>")
|
|
b.WriteString("<div style=\"display:flex;gap:8px;margin-top:8px\"><button class=\"btn\" type=\"submit\">Adicionar</button><a class=\"btn2\" href=\"" + template.HTMLEscapeString(back) + "\">Voltar</a></div>")
|
|
b.WriteString("</form>")
|
|
b.WriteString("</div>")
|
|
} else {
|
|
b.WriteString("<p class=\"badge\" style=\"margin-top:12px\">Faça login novamente informando Operador para comentar.</p>")
|
|
}
|
|
|
|
b.WriteString("</div></body></html>")
|
|
w.Write([]byte(b.String()))
|
|
}
|
|
|
|
func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
formatarDataPainel := func(t time.Time) string {
|
|
loc, err := time.LoadLocation("America/Sao_Paulo")
|
|
if err != nil {
|
|
loc = time.FixedZone("America/Sao_Paulo", -3*60*60)
|
|
}
|
|
return t.In(loc).Format("02/01/2006 15:04")
|
|
}
|
|
|
|
isConcluida := func(id string) bool {
|
|
if d.StatusAnalise == nil {
|
|
return false
|
|
}
|
|
if st, ok := d.StatusAnalise[id]; ok {
|
|
return st == contratos.StatusAnaliseConcluida
|
|
}
|
|
return false
|
|
}
|
|
|
|
trunc := func(s string, max int) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
s = strings.ReplaceAll(s, "\r", " ")
|
|
for strings.Contains(s, " ") {
|
|
s = strings.ReplaceAll(s, " ", " ")
|
|
}
|
|
if max <= 0 {
|
|
return ""
|
|
}
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "…"
|
|
}
|
|
|
|
soDigitos := func(s string) string {
|
|
return naoDigitosRe.ReplaceAllString(s, "")
|
|
}
|
|
|
|
linkEmail := func(email string) string {
|
|
email = strings.TrimSpace(email)
|
|
if email == "" {
|
|
return "-"
|
|
}
|
|
href := "mailto:" + url.PathEscape(email)
|
|
txt := template.HTMLEscapeString(email)
|
|
return "<a href=\"" + href + "\">" + txt + "</a>"
|
|
}
|
|
|
|
linkTelefone := func(telOriginal string) (hrefTel string, hrefWA string, htmlTel string) {
|
|
telOriginal = strings.TrimSpace(telOriginal)
|
|
if telOriginal == "" {
|
|
return "", "", "-"
|
|
}
|
|
dig := soDigitos(telOriginal)
|
|
if dig == "" {
|
|
return "", "", template.HTMLEscapeString(telOriginal)
|
|
}
|
|
hrefTel = "tel:+" + dig
|
|
waNum := dig
|
|
if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") {
|
|
waNum = "55" + waNum
|
|
}
|
|
hrefWA = "https://wa.me/" + waNum
|
|
htmlTel = "<a href=\"" + hrefTel + "\">" + template.HTMLEscapeString(telOriginal) + "</a>"
|
|
return hrefTel, hrefWA, htmlTel
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("<!doctype html><html lang=\"pt-br\"><head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>")
|
|
b.WriteString("<title>e-li.nps • Painel</title>")
|
|
b.WriteString(`<style>
|
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;padding:18px;background:#fafafa;color:#111;}
|
|
.top{display:flex;gap:12px;flex-wrap:wrap;align-items:center;}
|
|
.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:14px;}
|
|
select,input{padding:10px;border:1px solid #ddd;border-radius:10px;}
|
|
a{color:#111}
|
|
table{width:100%;border-collapse:collapse;font-size:13px;}
|
|
th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:top;}
|
|
.muted{color:#666;font-size:12px;}
|
|
.badge{display:inline-block;padding:2px 8px;border-radius:999px;background:#f2f2f2;border:1px solid #e5e5e5;font-size:12px;}
|
|
.iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:10px;border:1px solid #e5e5e5;background:#fff;margin-right:6px;text-decoration:none;}
|
|
.iconbtn:hover{border-color:#bbb;background:#fafafa;}
|
|
.icon{width:16px;height:16px;display:block;}
|
|
|
|
/* Responsivo */
|
|
.tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch;}
|
|
.tablewrap table{min-width:900px;}
|
|
|
|
@media (max-width: 640px){
|
|
body{padding:12px;}
|
|
.card{padding:12px;}
|
|
h1{font-size:18px;}
|
|
}
|
|
</style></head><body>`)
|
|
|
|
b.WriteString("<div class=\"top\">")
|
|
b.WriteString("<div class=\"card\"><h1 style=\"margin:0 0 8px\">e-li.nps • Painel</h1>")
|
|
if d.MsgErro != "" {
|
|
b.WriteString("<p class=\"badge\">" + template.HTMLEscapeString(d.MsgErro) + "</p>")
|
|
}
|
|
if strings.TrimSpace(d.Operador) != "" {
|
|
b.WriteString("<p class=\"muted\" style=\"margin:0 0 8px\">Operador: <b>" + template.HTMLEscapeString(d.Operador) + "</b></p>")
|
|
}
|
|
b.WriteString("<form method=\"GET\" action=\"/painel\" style=\"display:flex;gap:10px;flex-wrap:wrap;align-items:center\">")
|
|
b.WriteString("<label class=\"muted\">Produto</label>")
|
|
b.WriteString("<select name=\"produto\">")
|
|
for _, p := range d.Produtos {
|
|
sel := ""
|
|
if p == d.Produto {
|
|
sel = " selected"
|
|
}
|
|
b.WriteString("<option value=\"" + template.HTMLEscapeString(p) + "\"" + sel + ">" + template.HTMLEscapeString(p) + "</option>")
|
|
}
|
|
b.WriteString("</select>")
|
|
chk := ""
|
|
if d.SomenteBaixas {
|
|
chk = "checked"
|
|
}
|
|
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</label>")
|
|
chkPend := ""
|
|
if d.SomentePendentes {
|
|
chkPend = "checked"
|
|
}
|
|
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"pendentes\" value=\"1\" " + chkPend + "/> somente pendentes</label>")
|
|
b.WriteString("<button type=\"submit\" style=\"padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer\">Aplicar</button>")
|
|
|
|
// Export CSV (mantém filtros atuais)
|
|
exportURL := "/painel/export.csv?produto=" + url.QueryEscape(d.Produto)
|
|
if d.SomenteBaixas {
|
|
exportURL += "&baixas=1"
|
|
}
|
|
if d.SomentePendentes {
|
|
exportURL += "&pendentes=1"
|
|
}
|
|
b.WriteString("<a class=\"badge\" style=\"text-decoration:none;display:inline-block;padding:10px 14px\" href=\"" + exportURL + "\">Exportar CSV</a>")
|
|
|
|
b.WriteString("</form></div>")
|
|
b.WriteString("</div>")
|
|
|
|
// NPS mês a mês
|
|
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">NPS mês a mês</h2>")
|
|
b.WriteString("<div class=\"tablewrap\"><table><thead><tr><th>Mês</th><th>Detratores</th><th>Neutros</th><th>Promotores</th><th>Total</th><th>NPS</th></tr></thead><tbody>")
|
|
for _, m := range d.Meses {
|
|
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%d</td><td>%d</td><td>%d</td><td>%d</td><td><b>%d</b></td></tr>",
|
|
template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS))
|
|
}
|
|
b.WriteString("</tbody></table></div></div>")
|
|
|
|
// Respostas
|
|
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>")
|
|
if strings.TrimSpace(d.Operador) == "" {
|
|
b.WriteString("<p class=\"badge\">Defina um operador (faça login novamente) para comentar e concluir respostas.</p>")
|
|
}
|
|
b.WriteString("<div class=\"tablewrap\"><table><thead><tr><th>Data</th><th>Nota</th><th>Pendente?</th><th>Usuário</th><th>Inquilino</th><th>Email</th><th>Telefone</th><th>Justificativa</th><th>Último comentário</th><th>Ações</th></tr></thead><tbody>")
|
|
for _, r := range d.Respostas {
|
|
data := "-"
|
|
if r.RespondidoEm != nil {
|
|
data = formatarDataPainel(*r.RespondidoEm)
|
|
} else {
|
|
data = formatarDataPainel(r.PedidoCriadoEm)
|
|
}
|
|
nota := "-"
|
|
if r.Nota != nil {
|
|
nota = fmt.Sprintf("%d", *r.Nota)
|
|
}
|
|
usuario := template.HTMLEscapeString(r.UsuarioNome)
|
|
if r.UsuarioCodigo != nil {
|
|
usuario += " <span class=\"muted\">(" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")</span>"
|
|
}
|
|
inquilino := template.HTMLEscapeString(r.InquilinoNome) + " <span class=\"muted\">(" + template.HTMLEscapeString(r.InquilinoCodigo) + ")</span>"
|
|
|
|
emailHTML := "-"
|
|
emailHref := ""
|
|
if r.UsuarioEmail != nil && strings.TrimSpace(*r.UsuarioEmail) != "" {
|
|
emailHTML = linkEmail(*r.UsuarioEmail)
|
|
emailHref = "mailto:" + url.PathEscape(strings.TrimSpace(*r.UsuarioEmail))
|
|
}
|
|
|
|
telHref := ""
|
|
waHref := ""
|
|
telefoneHTML := "-"
|
|
if r.UsuarioTelefone != nil && strings.TrimSpace(*r.UsuarioTelefone) != "" {
|
|
telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone)
|
|
}
|
|
|
|
iconMail := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6h16v12H4V6Z" stroke="currentColor" stroke-width="2"/><path d="m4 7 8 6 8-6" stroke="currentColor" stroke-width="2"/></svg>`
|
|
iconPhone := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 3h4l2 6-3 2c1.5 3 4 5.5 7 7l2-3 6 2v4c0 1-1 2-2 2C11 23 1 13 2 5c0-1 1-2 2-2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>`
|
|
iconWA := `<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 11.5c0 4.142-3.582 7.5-8 7.5-1.403 0-2.722-.339-3.87-.937L4 19l1.05-3.322A7.08 7.08 0 0 1 4 11.5C4 7.358 7.582 4 12 4s8 3.358 8 7.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>`
|
|
|
|
acoes := ""
|
|
if emailHref != "" {
|
|
acoes += "<a class=\"iconbtn\" href=\"" + emailHref + "\" title=\"Enviar email\">" + iconMail + "</a>"
|
|
}
|
|
if waHref != "" {
|
|
acoes += "<a class=\"iconbtn\" href=\"" + waHref + "\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Abrir WhatsApp\">" + iconWA + "</a>"
|
|
}
|
|
if telHref != "" {
|
|
acoes += "<a class=\"iconbtn\" href=\"" + telHref + "\" title=\"Ligar\">" + iconPhone + "</a>"
|
|
}
|
|
if acoes == "" {
|
|
acoes = "-"
|
|
}
|
|
|
|
justificativa := ""
|
|
if r.Justificativa != nil {
|
|
justificativa = template.HTMLEscapeString(*r.Justificativa)
|
|
}
|
|
|
|
// Pendente?
|
|
pendente := !isConcluida(r.ID)
|
|
pendenteTxt := "Sim"
|
|
if !pendente {
|
|
pendenteTxt = "Não"
|
|
}
|
|
badgePendente := "<span class=\"badge\">" + template.HTMLEscapeString(pendenteTxt) + "</span>"
|
|
qBase := "?produto=" + url.QueryEscape(d.Produto)
|
|
if d.SomenteBaixas {
|
|
qBase += "&baixas=1"
|
|
}
|
|
if d.SomentePendentes {
|
|
qBase += "&pendentes=1"
|
|
}
|
|
qBase += "&pagina=" + fmt.Sprintf("%d", d.Pagina)
|
|
statusCell := badgePendente
|
|
|
|
// Último comentário (resumo)
|
|
comentarios := d.Comentarios[r.ID]
|
|
ultimo := "-"
|
|
if len(comentarios) > 0 {
|
|
c := comentarios[len(comentarios)-1]
|
|
ultimo = template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + " — " + template.HTMLEscapeString(trunc(c.Comentario, 90))
|
|
}
|
|
|
|
// Botão ação: abre modal server-side (sem JS)
|
|
acaoModal := "-"
|
|
if strings.TrimSpace(d.Produto) != "" {
|
|
acaoModal = "<a class=\"badge\" style=\"text-decoration:none\" href=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/comentarios" + qBase + "\">Comentários</a>"
|
|
}
|
|
|
|
// Botão de ação: alternar status (concluída/pendente)
|
|
actionToggle := ""
|
|
if strings.TrimSpace(d.Operador) != "" {
|
|
if pendente {
|
|
actionToggle = "<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/concluir" + qBase + "\" style=\"display:inline\"><button class=\"badge\" type=\"submit\" style=\"cursor:pointer\">Concluir</button></form>"
|
|
} else {
|
|
actionToggle = "<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/reabrir" + qBase + "\" style=\"display:inline\"><button class=\"badge\" type=\"submit\" style=\"cursor:pointer\">Reabrir</button></form>"
|
|
}
|
|
}
|
|
|
|
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + statusCell + "</td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + justificativa + "</td><td>" + ultimo + "</td><td>" + acoes + " " + actionToggle + " " + acaoModal + "</td></tr>")
|
|
}
|
|
b.WriteString("</tbody></table></div>")
|
|
|
|
// Navegação
|
|
base := "/painel?produto=" + url.QueryEscape(d.Produto)
|
|
if d.SomenteBaixas {
|
|
base += "&baixas=1"
|
|
}
|
|
if d.SomentePendentes {
|
|
base += "&pendentes=1"
|
|
}
|
|
prev := d.Pagina - 1
|
|
if prev < 1 {
|
|
prev = 1
|
|
}
|
|
next := d.Pagina + 1
|
|
b.WriteString("<div style=\"display:flex;gap:10px;justify-content:flex-end;margin-top:10px\">")
|
|
b.WriteString("<a class=\"badge\" href=\"" + base + "&pagina=" + fmt.Sprintf("%d", prev) + "\">Anterior</a>")
|
|
b.WriteString("<span class=\"muted\">Página " + fmt.Sprintf("%d", d.Pagina) + "</span>")
|
|
b.WriteString("<a class=\"badge\" href=\"" + base + "&pagina=" + fmt.Sprintf("%d", next) + "\">Próxima</a>")
|
|
b.WriteString("</div>")
|
|
|
|
b.WriteString("</div>")
|
|
|
|
// JS do painel: bootstrap do WASM.
|
|
b.WriteString(`<script src="/static/wasm_exec.js"></script><script src="/static/painel.js"></script>`)
|
|
|
|
b.WriteString("</body></html>")
|
|
w.Write([]byte(b.String()))
|
|
}
|