diff --git a/internal/elinps/painel.go b/internal/elinps/painel.go
index 690617b..3847b82 100644
--- a/internal/elinps/painel.go
+++ b/internal/elinps/painel.go
@@ -2,11 +2,13 @@ package elinps
import (
"crypto/subtle"
+ "encoding/csv"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
+ "strconv"
"strings"
"time"
@@ -18,15 +20,17 @@ import (
// Proteção simples do painel administrativo.
//
// Objetivo: bloquear acesso ao painel com uma senha definida no .env.
-// Implementação: cookie assinado de forma simples (token aleatório por boot).
+// Implementação: cookie simples com token aleatório por boot.
//
-// Observação: é propositalmente simples (sem banco) para manter o projeto leve.
-// Se precisar evoluir depois, podemos trocar por JWT ou sessão persistida.
+// Observação: propositalmente simples (sem banco) para manter o projeto leve.
type AuthPainel struct {
Senha 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) {
if !a.habilitado() {
w.WriteHeader(http.StatusUnauthorized)
@@ -59,10 +63,66 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
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(`
+
+
+
+
+ e-li.nps • Painel
+
+
+
+
+
e-li.nps • Painel
+
Acesso protegido por senha (SENHA_PAINEL).
+
+
+
+`))
+}
+
+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 RespostaPainel = contratos.RespostaPainel
-
type PainelDados = contratos.PainelDados
func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) {
@@ -119,7 +179,6 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50})
if err != nil {
- // Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar.
if err == pgx.ErrNoRows {
respostas = []contratos.RespostaPainel{}
} else {
@@ -131,26 +190,159 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
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) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
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")
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)
}
return t.In(loc).Format("02/01/2006 15:04")
}
soDigitos := func(s string) string {
- // 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, "")
+ return naoDigitosRe.ReplaceAllString(s, "")
}
linkEmail := func(email string) string {
@@ -158,7 +350,6 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
if email == "" {
return "-"
}
- // mailto aceita percent-encoding; usar PathEscape evita injeção no href.
href := "mailto:" + url.PathEscape(email)
txt := template.HTMLEscapeString(email)
return "" + txt + ""
@@ -171,27 +362,18 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
}
dig := soDigitos(telOriginal)
if dig == "" {
- // Se não há dígitos, exibimos apenas como texto.
return "", "", template.HTMLEscapeString(telOriginal)
}
-
- // tel: — preferimos com '+' quando possível.
hrefTel = "tel:+" + dig
- // WhatsApp wa.me exige número em formato internacional com dígitos.
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") {
waNum = "55" + waNum
}
hrefWA = "https://wa.me/" + waNum
-
- // Exibição do número: mantém o original (mais amigável), mas escapado.
htmlTel = "" + template.HTMLEscapeString(telOriginal) + ""
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
b.WriteString("")
b.WriteString("e-li.nps • Painel")
@@ -208,6 +390,16 @@ 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: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;}
+}
`)
b.WriteString("")
@@ -232,27 +424,34 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
}
b.WriteString("
")
b.WriteString("
")
+
+ // Export CSV (mantém filtros atuais)
+ exportURL := "/painel/export.csv?produto=" + url.QueryEscape(d.Produto)
+ if d.SomenteBaixas {
+ exportURL += "&baixas=1"
+ }
+ b.WriteString("
Exportar CSV")
+
b.WriteString("
")
b.WriteString("")
// NPS mês a mês
b.WriteString("NPS mês a mês
")
- b.WriteString("
| Mês | Detratores | Neutros | Promotores | Total | NPS |
")
+ b.WriteString("| Mês | Detratores | Neutros | Promotores | Total | NPS |
")
for _, m := range d.Meses {
b.WriteString(fmt.Sprintf("| %s | %d | %d | %d | %d | %d |
",
template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS))
}
- b.WriteString("
")
+ b.WriteString("
")
// Respostas
b.WriteString("Respostas
")
- b.WriteString("
| Data | Nota | Usuário | Inquilino | Email | Telefone | Comentário | Ações |
")
+ b.WriteString("| Data | Nota | Usuário | Inquilino | Email | Telefone | Comentário | Ações |
")
for _, r := range d.Respostas {
data := "-"
if r.RespondidoEm != nil {
data = formatarDataPainel(*r.RespondidoEm)
} else {
- // fallback: apesar do painel listar "respondido", mantemos robustez.
data = formatarDataPainel(r.PedidoCriadoEm)
}
nota := "-"
@@ -264,12 +463,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
usuario += " (" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")"
}
inquilino := template.HTMLEscapeString(r.InquilinoNome) + " (" + template.HTMLEscapeString(r.InquilinoCodigo) + ")"
+
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 := "-"
@@ -277,7 +478,6 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone)
}
- // Ícones (inline SVG) — simples e sem dependências.
iconMail := ``
iconPhone := ``
iconWA := ``
@@ -295,13 +495,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
if acoes == "" {
acoes = "-"
}
+
coment := ""
if r.Justificativa != nil {
coment = template.HTMLEscapeString(*r.Justificativa)
}
b.WriteString("| " + template.HTMLEscapeString(data) + " | " + template.HTMLEscapeString(nota) + " | " + usuario + " | " + inquilino + " | " + emailHTML + " | " + telefoneHTML + " | " + coment + " | " + acoes + " |
")
}
- b.WriteString("
")
+ b.WriteString("
")
// Navegação
base := "/painel?produto=" + url.QueryEscape(d.Produto)
@@ -321,73 +522,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
b.WriteString("")
- // JS do painel: apenas bootstrap para executar a lógica no WASM.
- // Regras (.agent): sem dependências externas. A lógica fica no WASM.
+ // JS do painel: bootstrap do WASM.
b.WriteString(``)
b.WriteString("")
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(`
-
-
-
-
- e-li.nps • Painel
-
-
-
-
-
e-li.nps • Painel
-
Acesso protegido por senha (SENHA_PAINEL).
-
-
-
-`))
-}
-
-// (handlerLoginPost duplicado removido)
diff --git a/internal/elinps/painel_handlers.go b/internal/elinps/painel_handlers.go
index 7720b8e..85d92c0 100644
--- a/internal/elinps/painel_handlers.go
+++ b/internal/elinps/painel_handlers.go
@@ -40,6 +40,12 @@ func (p *PainelHandlers) Router() http.Handler {
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.
// 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) {
diff --git a/internal/elinps/painel_queries.go b/internal/elinps/painel_queries.go
index 2eb3c39..22ac1ee 100644
--- a/internal/elinps/painel_queries.go
+++ b/internal/elinps/painel_queries.go
@@ -100,6 +100,10 @@ type ListarRespostasFiltro struct {
func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() }
+type ExportarRespostasFiltro struct {
+ SomenteNotasBaixas bool
+}
+
// ListarRespostas retorna respostas respondidas, com paginação e filtro.
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
@@ -165,6 +169,45 @@ LIMIT $1 OFFSET $2`, tabela, cond)
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
var _ = pgx.ErrNoRows
var _ = time.Second