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("") + b.WriteString("
DataNotaUsuárioComentário
") 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("") + b.WriteString("") } b.WriteString("
DataNotaUsuárioInquilinoEmailTelefoneComentárioAções
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + usuario + "" + coment + "
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + usuario + "" + inquilino + "" + emailHTML + "" + telefoneHTML + "" + coment + "" + acoes + "
") @@ -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("") w.Write([]byte(b.String())) } diff --git a/internal/elinps/painel_queries.go b/internal/elinps/painel_queries.go index 8b03642..2eb3c39 100644 --- a/internal/elinps/painel_queries.go +++ b/internal/elinps/painel_queries.go @@ -123,9 +123,12 @@ SELECT id, respondido_em, pedido_criado_em, + inquilino_codigo, + inquilino_nome, usuario_codigo, usuario_nome, usuario_email, + usuario_telefone, nota, justificativa FROM %s @@ -146,9 +149,12 @@ LIMIT $1 OFFSET $2`, tabela, cond) &r.ID, &r.RespondidoEm, &r.PedidoCriadoEm, + &r.InquilinoCodigo, + &r.InquilinoNome, &r.UsuarioCodigo, &r.UsuarioNome, &r.UsuarioEmail, + &r.UsuarioTelefone, &r.Nota, &r.Justificativa, ); err != nil { diff --git a/server b/server index dd5ff33..6df7008 100755 Binary files a/server and b/server differ diff --git a/web/static/e-li.nps.wasm b/web/static/e-li.nps.wasm index d533202..b544181 100755 Binary files a/web/static/e-li.nps.wasm and b/web/static/e-li.nps.wasm differ diff --git a/web/static/painel.js b/web/static/painel.js new file mode 100644 index 0000000..4726e5a --- /dev/null +++ b/web/static/painel.js @@ -0,0 +1,49 @@ +(function(){ + // Bootstrap mínimo do painel. + // Regra (.agent): a lógica fica no WASM; aqui apenas garantimos que o WASM + // esteja carregado/executando. + // + // Este arquivo existe para evitar JS inline no HTML do painel. + + async function carregarWasmPainel(){ + try{ + if(window.__eli_nps_wasm_ready) return true; + if(window.__eli_nps_wasm_loading) return window.__eli_nps_wasm_loading; + + window.__eli_nps_wasm_loading = (async function(){ + try{ + if(!window.Go){ + // wasm_exec.js deve ter sido carregado pelo HTML. + return false; + } + const go = new Go(); + const res = await fetch('/static/e-li.nps.wasm', {cache: 'no-cache'}); + if(!res.ok) return false; + const bytes = await res.arrayBuffer(); + const {instance} = await WebAssembly.instantiate(bytes, go.importObject); + go.run(instance); + return !!window.__eli_nps_wasm_ready; + }catch(e){ + return false; + } + })(); + + return window.__eli_nps_wasm_loading; + }catch(e){ + return false; + } + } + + document.addEventListener('DOMContentLoaded', function(){ + carregarWasmPainel().then(function(ok){ + if(!ok) return; + // Executa o init do painel no WASM (best-effort). + try{ + if(typeof window.__eli_nps_wasm_painel_init === 'function'){ + window.__eli_nps_wasm_painel_init(); + } + }catch(e){} + }); + }); +})(); +