Compare commits
No commits in common. "0bbd04ee458d24973cf3975adc9a3f348f12878a" and "65118f28386eb75010a88868085c9033c12e5aa0" have entirely different histories.
0bbd04ee45
...
65118f2838
5 changed files with 95 additions and 306 deletions
16
.agent
16
.agent
|
|
@ -33,21 +33,6 @@ project_stack:
|
||||||
optional_logic_layer:
|
optional_logic_layer:
|
||||||
- "Go → WebAssembly (WASM), apenas se adotado explicitamente no projeto"
|
- "Go → WebAssembly (WASM), apenas se adotado explicitamente no projeto"
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# Painel administrativo (atualizações recentes)
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
painel_policy:
|
|
||||||
- "Painel é server-side HTML (sem framework) e protegido por SENHA_PAINEL."
|
|
||||||
- "O painel deve ser usável em mobile: tabelas com scroll horizontal (overflow-x) e layout responsivo."
|
|
||||||
- "O painel exibe datas no fuso de Brasília (America/Sao_Paulo) no formato DD/MM/YYYY HH:mm."
|
|
||||||
- "Email deve ser exibido como link mailto:."
|
|
||||||
- "Telefone deve ter link tel: e ação para WhatsApp (https://wa.me/...), com normalização best-effort (somente dígitos; prefixo 55 quando parecer número BR de 10/11 dígitos)."
|
|
||||||
- "Ações do painel devem ser botões/ícones sem dependências externas (ex.: SVG inline)."
|
|
||||||
- "Persistência de preferências do painel (produto selecionado e filtro 'baixas') deve ficar no WASM (Go→WASM), sem JS de lógica inline no HTML."
|
|
||||||
- "O HTML do painel pode conter apenas bootstrap mínimo para carregar WASM (wasm_exec.js + painel.js)."
|
|
||||||
- "Exportação CSV do painel deve ser via endpoint protegido e streaming (sem carregar tudo em memória) e respeitar filtros atuais (produto e baixas)."
|
|
||||||
- "CSV deve usar ';' como separador e incluir BOM UTF-8 para compatibilidade com Excel PT-BR."
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Regras gerais
|
# Regras gerais
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|
@ -57,7 +42,6 @@ rules:
|
||||||
- "Evitar mudanças que quebrem widgets já embedados em clientes."
|
- "Evitar mudanças que quebrem widgets já embedados em clientes."
|
||||||
- "Mudanças que impactem desenvolvedores OU usuários DEVEM ser documentadas."
|
- "Mudanças que impactem desenvolvedores OU usuários DEVEM ser documentadas."
|
||||||
- "Código deve ser previsível, explícito e fácil de auditar."
|
- "Código deve ser previsível, explícito e fácil de auditar."
|
||||||
- "Evitar dependências externas no painel e no widget (inclui ícones, libs e CDNs)."
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Linguagem, nomes e comentários
|
# Linguagem, nomes e comentários
|
||||||
|
|
|
||||||
|
|
@ -222,15 +222,6 @@ Depois acesse:
|
||||||
- Teste do widget: `http://localhost:8080/teste.html`
|
- Teste do widget: `http://localhost:8080/teste.html`
|
||||||
- Painel: `http://localhost:8080/painel` (senha em `SENHA_PAINEL`)
|
- Painel: `http://localhost:8080/painel` (senha em `SENHA_PAINEL`)
|
||||||
|
|
||||||
Exportação CSV (painel):
|
|
||||||
|
|
||||||
- Endpoint protegido: `GET /painel/export.csv`
|
|
||||||
- Parâmetros:
|
|
||||||
- `produto=<produto>` (obrigatório)
|
|
||||||
- `baixas=1` (opcional; exporta apenas notas baixas <=6)
|
|
||||||
- Exemplo:
|
|
||||||
- `http://localhost:8080/painel/export.csv?produto=exemplo&baixas=1`
|
|
||||||
|
|
||||||
Painel:
|
Painel:
|
||||||
|
|
||||||
- Acesse `http://localhost:8080/painel`
|
- Acesse `http://localhost:8080/painel`
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ package elinps
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -20,17 +18,15 @@ import (
|
||||||
// Proteção simples do painel administrativo.
|
// Proteção simples do painel administrativo.
|
||||||
//
|
//
|
||||||
// Objetivo: bloquear acesso ao painel com uma senha definida no .env.
|
// Objetivo: bloquear acesso ao painel com uma senha definida no .env.
|
||||||
// Implementação: cookie simples com token aleatório por boot.
|
// Implementação: cookie assinado de forma simples (token aleatório por boot).
|
||||||
//
|
//
|
||||||
// Observação: propositalmente simples (sem banco) para manter o projeto leve.
|
// Observação: é propositalmente simples (sem banco) para manter o projeto leve.
|
||||||
|
// Se precisar evoluir depois, podemos trocar por JWT ou sessão persistida.
|
||||||
type AuthPainel struct {
|
type AuthPainel struct {
|
||||||
Senha string
|
Senha string
|
||||||
Token string
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regex pré-compilada (evita recompilar a cada request).
|
|
||||||
var naoDigitosRe = regexp.MustCompile(`\D`)
|
|
||||||
|
|
||||||
func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
|
func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.habilitado() {
|
if !a.habilitado() {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
|
@ -63,66 +59,10 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/painel", http.StatusFound)
|
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>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 "eli_nps_painel" }
|
|
||||||
|
|
||||||
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 (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 NPSMensal = contratos.NPSMensal
|
||||||
|
|
||||||
type RespostaPainel = contratos.RespostaPainel
|
type RespostaPainel = contratos.RespostaPainel
|
||||||
|
|
||||||
type PainelDados = contratos.PainelDados
|
type PainelDados = contratos.PainelDados
|
||||||
|
|
||||||
func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) {
|
func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) {
|
||||||
|
|
@ -179,6 +119,7 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
|
||||||
|
|
||||||
respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50})
|
respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar.
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
respostas = []contratos.RespostaPainel{}
|
respostas = []contratos.RespostaPainel{}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -190,159 +131,26 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
|
||||||
a.renderPainelHTML(w, dados)
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
|
func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
formatarDataPainel := func(t time.Time) string {
|
formatarDataPainel := func(t time.Time) string {
|
||||||
|
// O banco armazena timestamptz (UTC normalizado). O requisito do painel é
|
||||||
|
// exibir no fuso de Brasília.
|
||||||
loc, err := time.LoadLocation("America/Sao_Paulo")
|
loc, err := time.LoadLocation("America/Sao_Paulo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Fallback best-effort (sem regras de DST antigas, mas suficiente para
|
||||||
|
// ambientes sem tzdata).
|
||||||
loc = time.FixedZone("America/Sao_Paulo", -3*60*60)
|
loc = time.FixedZone("America/Sao_Paulo", -3*60*60)
|
||||||
}
|
}
|
||||||
return t.In(loc).Format("02/01/2006 15:04")
|
return t.In(loc).Format("02/01/2006 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
soDigitos := func(s string) string {
|
soDigitos := func(s string) string {
|
||||||
return naoDigitosRe.ReplaceAllString(s, "")
|
// Normalização best-effort para criar links tel/WhatsApp.
|
||||||
|
// Mantemos apenas dígitos e descartamos qualquer outro caractere.
|
||||||
|
re := regexp.MustCompile(`\D`)
|
||||||
|
return re.ReplaceAllString(s, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
linkEmail := func(email string) string {
|
linkEmail := func(email string) string {
|
||||||
|
|
@ -350,6 +158,7 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
|
// mailto aceita percent-encoding; usar PathEscape evita injeção no href.
|
||||||
href := "mailto:" + url.PathEscape(email)
|
href := "mailto:" + url.PathEscape(email)
|
||||||
txt := template.HTMLEscapeString(email)
|
txt := template.HTMLEscapeString(email)
|
||||||
return "<a href=\"" + href + "\">" + txt + "</a>"
|
return "<a href=\"" + href + "\">" + txt + "</a>"
|
||||||
|
|
@ -362,18 +171,27 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
|
||||||
}
|
}
|
||||||
dig := soDigitos(telOriginal)
|
dig := soDigitos(telOriginal)
|
||||||
if dig == "" {
|
if dig == "" {
|
||||||
|
// Se não há dígitos, exibimos apenas como texto.
|
||||||
return "", "", template.HTMLEscapeString(telOriginal)
|
return "", "", template.HTMLEscapeString(telOriginal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tel: — preferimos com '+' quando possível.
|
||||||
hrefTel = "tel:+" + dig
|
hrefTel = "tel:+" + dig
|
||||||
|
// WhatsApp wa.me exige número em formato internacional com dígitos.
|
||||||
waNum := dig
|
waNum := dig
|
||||||
|
// Heurística: se parece número BR (10/11 dígitos) e não tem DDI, prefixa 55.
|
||||||
if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") {
|
if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") {
|
||||||
waNum = "55" + waNum
|
waNum = "55" + waNum
|
||||||
}
|
}
|
||||||
hrefWA = "https://wa.me/" + waNum
|
hrefWA = "https://wa.me/" + waNum
|
||||||
|
|
||||||
|
// Exibição do número: mantém o original (mais amigável), mas escapado.
|
||||||
htmlTel = "<a href=\"" + hrefTel + "\">" + template.HTMLEscapeString(telOriginal) + "</a>"
|
htmlTel = "<a href=\"" + hrefTel + "\">" + template.HTMLEscapeString(telOriginal) + "</a>"
|
||||||
return hrefTel, hrefWA, htmlTel
|
return hrefTel, hrefWA, htmlTel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTML propositalmente simples (sem template engine) para manter isolado.
|
||||||
|
// Se quiser evoluir, dá pra migrar para templates.
|
||||||
var b strings.Builder
|
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("<!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("<title>e-li.nps • Painel</title>")
|
||||||
|
|
@ -390,16 +208,6 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
||||||
.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{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;}
|
.iconbtn:hover{border-color:#bbb;background:#fafafa;}
|
||||||
.icon{width:16px;height:16px;display:block;}
|
.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>`)
|
</style></head><body>`)
|
||||||
|
|
||||||
b.WriteString("<div class=\"top\">")
|
b.WriteString("<div class=\"top\">")
|
||||||
|
|
@ -424,34 +232,27 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
||||||
}
|
}
|
||||||
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</label>")
|
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</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>")
|
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"
|
|
||||||
}
|
|
||||||
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("</form></div>")
|
||||||
b.WriteString("</div>")
|
b.WriteString("</div>")
|
||||||
|
|
||||||
// NPS mês a mês
|
// 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=\"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>")
|
b.WriteString("<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 {
|
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>",
|
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))
|
template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS))
|
||||||
}
|
}
|
||||||
b.WriteString("</tbody></table></div></div>")
|
b.WriteString("</tbody></table></div>")
|
||||||
|
|
||||||
// Respostas
|
// Respostas
|
||||||
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>")
|
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>")
|
||||||
b.WriteString("<div class=\"tablewrap\"><table><thead><tr><th>Data</th><th>Nota</th><th>Usuário</th><th>Inquilino</th><th>Email</th><th>Telefone</th><th>Comentário</th><th>Ações</th></tr></thead><tbody>")
|
b.WriteString("<table><thead><tr><th>Data</th><th>Nota</th><th>Usuário</th><th>Inquilino</th><th>Email</th><th>Telefone</th><th>Comentário</th><th>Ações</th></tr></thead><tbody>")
|
||||||
for _, r := range d.Respostas {
|
for _, r := range d.Respostas {
|
||||||
data := "-"
|
data := "-"
|
||||||
if r.RespondidoEm != nil {
|
if r.RespondidoEm != nil {
|
||||||
data = formatarDataPainel(*r.RespondidoEm)
|
data = formatarDataPainel(*r.RespondidoEm)
|
||||||
} else {
|
} else {
|
||||||
|
// fallback: apesar do painel listar "respondido", mantemos robustez.
|
||||||
data = formatarDataPainel(r.PedidoCriadoEm)
|
data = formatarDataPainel(r.PedidoCriadoEm)
|
||||||
}
|
}
|
||||||
nota := "-"
|
nota := "-"
|
||||||
|
|
@ -463,14 +264,12 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
||||||
usuario += " <span class=\"muted\">(" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")</span>"
|
usuario += " <span class=\"muted\">(" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")</span>"
|
||||||
}
|
}
|
||||||
inquilino := template.HTMLEscapeString(r.InquilinoNome) + " <span class=\"muted\">(" + template.HTMLEscapeString(r.InquilinoCodigo) + ")</span>"
|
inquilino := template.HTMLEscapeString(r.InquilinoNome) + " <span class=\"muted\">(" + template.HTMLEscapeString(r.InquilinoCodigo) + ")</span>"
|
||||||
|
|
||||||
emailHTML := "-"
|
emailHTML := "-"
|
||||||
emailHref := ""
|
emailHref := ""
|
||||||
if r.UsuarioEmail != nil && strings.TrimSpace(*r.UsuarioEmail) != "" {
|
if r.UsuarioEmail != nil && strings.TrimSpace(*r.UsuarioEmail) != "" {
|
||||||
emailHTML = linkEmail(*r.UsuarioEmail)
|
emailHTML = linkEmail(*r.UsuarioEmail)
|
||||||
emailHref = "mailto:" + url.PathEscape(strings.TrimSpace(*r.UsuarioEmail))
|
emailHref = "mailto:" + url.PathEscape(strings.TrimSpace(*r.UsuarioEmail))
|
||||||
}
|
}
|
||||||
|
|
||||||
telHref := ""
|
telHref := ""
|
||||||
waHref := ""
|
waHref := ""
|
||||||
telefoneHTML := "-"
|
telefoneHTML := "-"
|
||||||
|
|
@ -478,6 +277,7 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
||||||
telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone)
|
telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ícones (inline SVG) — simples e sem dependências.
|
||||||
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>`
|
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>`
|
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>`
|
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>`
|
||||||
|
|
@ -495,14 +295,13 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
||||||
if acoes == "" {
|
if acoes == "" {
|
||||||
acoes = "-"
|
acoes = "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
coment := ""
|
coment := ""
|
||||||
if r.Justificativa != nil {
|
if r.Justificativa != nil {
|
||||||
coment = template.HTMLEscapeString(*r.Justificativa)
|
coment = template.HTMLEscapeString(*r.Justificativa)
|
||||||
}
|
}
|
||||||
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + coment + "</td><td>" + acoes + "</td></tr>")
|
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + coment + "</td><td>" + acoes + "</td></tr>")
|
||||||
}
|
}
|
||||||
b.WriteString("</tbody></table></div>")
|
b.WriteString("</tbody></table>")
|
||||||
|
|
||||||
// Navegação
|
// Navegação
|
||||||
base := "/painel?produto=" + url.QueryEscape(d.Produto)
|
base := "/painel?produto=" + url.QueryEscape(d.Produto)
|
||||||
|
|
@ -522,9 +321,73 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
|
||||||
|
|
||||||
b.WriteString("</div>")
|
b.WriteString("</div>")
|
||||||
|
|
||||||
// JS do painel: bootstrap do WASM.
|
// JS do painel: apenas bootstrap para executar a lógica no WASM.
|
||||||
|
// Regras (.agent): sem dependências externas. A lógica fica no WASM.
|
||||||
b.WriteString(`<script src="/static/wasm_exec.js"></script><script src="/static/painel.js"></script>`)
|
b.WriteString(`<script src="/static/wasm_exec.js"></script><script src="/static/painel.js"></script>`)
|
||||||
|
|
||||||
b.WriteString("</body></html>")
|
b.WriteString("</body></html>")
|
||||||
w.Write([]byte(b.String()))
|
w.Write([]byte(b.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AuthPainel) habilitado() bool {
|
||||||
|
return a.Senha != "" && a.Token != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AuthPainel) cookieName() string { return "eli_nps_painel" }
|
||||||
|
|
||||||
|
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 (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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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;}
|
||||||
|
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>Senha</label>
|
||||||
|
<input type="password" name="senha" autocomplete="current-password" />
|
||||||
|
<button type="submit">Entrar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// (handlerLoginPost duplicado removido)
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,6 @@ func (p *PainelHandlers) Router() http.Handler {
|
||||||
p.auth.handlerPainel(w, r, p.store)
|
p.auth.handlerPainel(w, r, p.store)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Export CSV (todas as respostas do filtro atual)
|
|
||||||
// Protegido pelo mesmo middleware do painel.
|
|
||||||
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/export.csv", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p.auth.handlerExportCSV(w, r, p.store)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Debug: conferir IP real / headers.
|
// Debug: conferir IP real / headers.
|
||||||
// Protegido pelo mesmo middleware do painel.
|
// Protegido pelo mesmo middleware do painel.
|
||||||
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/debug/ip", func(w http.ResponseWriter, r *http.Request) {
|
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/debug/ip", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -100,10 +100,6 @@ type ListarRespostasFiltro struct {
|
||||||
|
|
||||||
func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() }
|
func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() }
|
||||||
|
|
||||||
type ExportarRespostasFiltro struct {
|
|
||||||
SomenteNotasBaixas bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListarRespostas retorna respostas respondidas, com paginação e filtro.
|
// ListarRespostas retorna respostas respondidas, com paginação e filtro.
|
||||||
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) {
|
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) {
|
||||||
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
||||||
|
|
@ -169,45 +165,6 @@ LIMIT $1 OFFSET $2`, tabela, cond)
|
||||||
return respostas, rows.Err()
|
return respostas, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportarRespostas abre um cursor/stream de respostas (sem paginação) para export.
|
|
||||||
//
|
|
||||||
// Importante: o caller deve SEMPRE fechar o rows.
|
|
||||||
func (s *Store) ExportarRespostas(ctx context.Context, tabela string, filtro ExportarRespostasFiltro) (pgx.Rows, error) {
|
|
||||||
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
|
|
||||||
if !db.TableNameValido(tabela) {
|
|
||||||
return nil, fmt.Errorf("tabela invalida")
|
|
||||||
}
|
|
||||||
|
|
||||||
cond := "status='respondido' AND valida=true"
|
|
||||||
if filtro.SomenteNotasBaixas {
|
|
||||||
cond += " AND nota BETWEEN 1 AND 6"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sem LIMIT/OFFSET (export completo).
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
respondido_em,
|
|
||||||
pedido_criado_em,
|
|
||||||
inquilino_codigo,
|
|
||||||
inquilino_nome,
|
|
||||||
usuario_codigo,
|
|
||||||
usuario_nome,
|
|
||||||
usuario_email,
|
|
||||||
usuario_telefone,
|
|
||||||
nota,
|
|
||||||
justificativa
|
|
||||||
FROM %s
|
|
||||||
WHERE %s
|
|
||||||
ORDER BY respondido_em DESC NULLS LAST`, tabela, cond)
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, q)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure interface imports
|
// ensure interface imports
|
||||||
var _ = pgx.ErrNoRows
|
var _ = pgx.ErrNoRows
|
||||||
var _ = time.Second
|
var _ = time.Second
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue