diff --git a/cmd/widgetwasm/main.go b/cmd/widgetwasm/main.go
index 931c4d1..222aa6f 100644
--- a/cmd/widgetwasm/main.go
+++ b/cmd/widgetwasm/main.go
@@ -3,6 +3,7 @@
package main
import (
+ "net/url"
"regexp"
"strings"
"syscall/js"
@@ -23,12 +24,128 @@ func main() {
js.Global().Set("__eli_nps_wasm_decidir", js.FuncOf(decidir))
js.Global().Set("__eli_nps_wasm_cooldown_ativo", js.FuncOf(cooldownAtivo))
js.Global().Set("__eli_nps_wasm_set_cooldown", js.FuncOf(setCooldown))
+ js.Global().Set("__eli_nps_wasm_painel_init", js.FuncOf(painelInit))
js.Global().Set("__eli_nps_wasm_ready", true)
// Mantém o módulo vivo.
select {}
}
+func painelInit(this js.Value, args []js.Value) any {
+ // Painel: persistência de filtros no localStorage.
+ // Regras (.agent): lógica no WASM (Go). Aqui é best-effort e fail-open.
+ //
+ // - Salva: produto selecionado + checkbox "baixas".
+ // - Restaura: se a URL não tiver parâmetros e houver valor salvo,
+ // redireciona para /painel?produto=...&baixas=1.
+ //
+ // Importante: não roda fora do /painel.
+ loc := js.Global().Get("location")
+ if loc.IsUndefined() || loc.IsNull() {
+ return nil
+ }
+ path := loc.Get("pathname").String()
+ if path == "" || !strings.HasPrefix(path, "/painel") {
+ return nil
+ }
+
+ storage := js.Global().Get("localStorage")
+ if storage.IsUndefined() || storage.IsNull() {
+ return nil
+ }
+
+ const keyProd = "eli_nps_painel_produto"
+ const keyBaixas = "eli_nps_painel_baixas"
+
+ // Aguarda o DOM estar pronto para conseguirmos acessar o form/option list.
+ doc := js.Global().Get("document")
+ if doc.IsUndefined() || doc.IsNull() {
+ return nil
+ }
+
+ handler := js.FuncOf(func(this js.Value, args []js.Value) any {
+ // Best-effort: evita exceptions se APIs não existirem.
+ defer func() { _ = recover() }()
+
+ form := doc.Call("querySelector", `form[action="/painel"]`)
+ if form.IsUndefined() || form.IsNull() {
+ return nil
+ }
+
+ sel := form.Call("querySelector", `select[name="produto"]`)
+ chk := form.Call("querySelector", `input[name="baixas"]`)
+
+ // Helper: verifica se option existe.
+ optionExists := func(selectEl js.Value, value string) bool {
+ if selectEl.IsUndefined() || selectEl.IsNull() {
+ return false
+ }
+ opts := selectEl.Get("options")
+ ln := opts.Get("length").Int()
+ for i := 0; i < ln; i++ {
+ opt := opts.Index(i)
+ if opt.Get("value").String() == value {
+ return true
+ }
+ }
+ return false
+ }
+
+ persist := func() {
+ if !sel.IsUndefined() && !sel.IsNull() {
+ v := strings.TrimSpace(sel.Get("value").String())
+ if v != "" {
+ storage.Call("setItem", keyProd, v)
+ }
+ }
+ baixas := "0"
+ if !chk.IsUndefined() && !chk.IsNull() && chk.Get("checked").Truthy() {
+ baixas = "1"
+ }
+ storage.Call("setItem", keyBaixas, baixas)
+ }
+
+ // Restaura/redirect apenas se não há query string.
+ s := loc.Get("search").String()
+ if strings.TrimSpace(s) == "" {
+ storedProd := strings.TrimSpace(storage.Call("getItem", keyProd).String())
+ storedBaixas := strings.TrimSpace(storage.Call("getItem", keyBaixas).String())
+ if storedProd != "" && optionExists(sel, storedProd) {
+ q := "?produto=" + url.QueryEscape(storedProd)
+ if storedBaixas == "1" {
+ q += "&baixas=1"
+ }
+ loc.Call("replace", path+q)
+ return nil
+ }
+ // Se só baixas estiver setado, aplica também.
+ if storedBaixas == "1" {
+ loc.Call("replace", path+"?baixas=1")
+ return nil
+ }
+ }
+
+ // Liga listeners para persistência.
+ onSubmit := js.FuncOf(func(this js.Value, args []js.Value) any { persist(); return nil })
+ onChange := js.FuncOf(func(this js.Value, args []js.Value) any { persist(); return nil })
+ form.Call("addEventListener", "submit", onSubmit)
+ form.Call("addEventListener", "change", onChange)
+
+ return nil
+ })
+
+ // DOMContentLoaded
+ readyState := doc.Get("readyState").String()
+ if readyState != "loading" {
+ // Se o WASM foi carregado depois do DOM pronto (comum no painel),
+ // rodamos imediatamente.
+ handler.Invoke()
+ } else {
+ doc.Call("addEventListener", "DOMContentLoaded", handler)
+ }
+ return nil
+}
+
func cooldownAtivo(this js.Value, args []js.Value) any {
if len(args) < 1 {
return false
diff --git a/internal/contratos/tipos.go b/internal/contratos/tipos.go
index 3eba408..89bd980 100644
--- a/internal/contratos/tipos.go
+++ b/internal/contratos/tipos.go
@@ -74,14 +74,17 @@ type NPSMensal struct {
// RespostaPainel representa uma resposta para listagem no painel.
type RespostaPainel struct {
- ID string
- RespondidoEm *time.Time
- PedidoCriadoEm time.Time
- UsuarioCodigo *string
- UsuarioNome string
- UsuarioEmail *string
- Nota *int
- Justificativa *string
+ ID string
+ RespondidoEm *time.Time
+ PedidoCriadoEm time.Time
+ InquilinoCodigo string
+ InquilinoNome string
+ UsuarioCodigo *string
+ UsuarioNome string
+ UsuarioEmail *string
+ UsuarioTelefone *string
+ Nota *int
+ Justificativa *string
}
type PainelDados struct {
diff --git a/internal/elinps/painel.go b/internal/elinps/painel.go
index cdbeff1..690617b 100644
--- a/internal/elinps/painel.go
+++ b/internal/elinps/painel.go
@@ -6,6 +6,7 @@ import (
"html/template"
"net/http"
"net/url"
+ "regexp"
"strings"
"time"
@@ -133,6 +134,62 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
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, "")
+ }
+
+ linkEmail := func(email string) string {
+ email = strings.TrimSpace(email)
+ 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 + " "
+ }
+
+ linkTelefone := func(telOriginal string) (hrefTel string, hrefWA string, htmlTel string) {
+ telOriginal = strings.TrimSpace(telOriginal)
+ if telOriginal == "" {
+ return "", "", "-"
+ }
+ 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
@@ -148,6 +205,9 @@ 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;}
`)
b.WriteString("")
@@ -186,11 +246,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
// Respostas
b.WriteString("
Respostas ")
- b.WriteString("
Data Nota Usuário Comentário ")
+ b.WriteString("Data Nota Usuário Inquilino Email Telefone Comentário Ações ")
for _, r := range d.Respostas {
data := "-"
if r.RespondidoEm != nil {
- data = r.RespondidoEm.Format("2006-01-02 15:04")
+ data = formatarDataPainel(*r.RespondidoEm)
+ } else {
+ // fallback: apesar do painel listar "respondido", mantemos robustez.
+ data = formatarDataPainel(r.PedidoCriadoEm)
}
nota := "-"
if r.Nota != nil {
@@ -200,11 +263,43 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
if r.UsuarioCodigo != nil {
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 := "-"
+ if r.UsuarioTelefone != nil && strings.TrimSpace(*r.UsuarioTelefone) != "" {
+ telHref, waHref, telefoneHTML = linkTelefone(*r.UsuarioTelefone)
+ }
+
+ // Ícones (inline SVG) — simples e sem dependências.
+ iconMail := ` `
+ iconPhone := ` `
+ iconWA := ` `
+
+ acoes := ""
+ if emailHref != "" {
+ acoes += "" + iconMail + " "
+ }
+ if waHref != "" {
+ acoes += "" + iconWA + " "
+ }
+ if telHref != "" {
+ acoes += "" + iconPhone + " "
+ }
+ if acoes == "" {
+ acoes = "-"
+ }
coment := ""
if r.Justificativa != nil {
coment = template.HTMLEscapeString(*r.Justificativa)
}
- b.WriteString("" + template.HTMLEscapeString(data) + " " + template.HTMLEscapeString(nota) + " " + usuario + " " + coment + " ")
+ b.WriteString("" + template.HTMLEscapeString(data) + " " + template.HTMLEscapeString(nota) + " " + usuario + " " + inquilino + " " + emailHTML + " " + telefoneHTML + " " + coment + " " + acoes + " ")
}
b.WriteString("
")
@@ -226,6 +321,10 @@ 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.
+ b.WriteString(``)
+
b.WriteString("