ajustes de respostas do painel

This commit is contained in:
Luiz Silva 2026-01-03 15:30:28 -03:00
parent 0d4ab67e0f
commit a0dd05b518
4 changed files with 115 additions and 11 deletions

View file

@ -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 {

View file

@ -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 "<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 == "" {
// 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 = "<a href=\"" + hrefTel + "\">" + template.HTMLEscapeString(telOriginal) + "</a>"
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;}
</style></head><body>`)
b.WriteString("<div class=\"top\">")
@ -186,11 +246,14 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
// Respostas
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>")
b.WriteString("<table><thead><tr><th>Data</th><th>Nota</th><th>Usuário</th><th>Comentário</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 {
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 += " <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)
}
// Í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>`
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 = "-"
}
coment := ""
if r.Justificativa != nil {
coment = template.HTMLEscapeString(*r.Justificativa)
}
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + coment + "</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>")

View file

@ -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 {

BIN
server

Binary file not shown.