package elinps
import (
crand "crypto/rand"
"crypto/subtle"
"encoding/csv"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"e-li.nps/internal/contratos"
"e-li.nps/internal/db"
chi "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
// Proteção simples do painel administrativo.
//
// Objetivo: bloquear acesso ao painel com uma senha definida no .env.
// Implementação: cookie simples com token aleatório por boot.
//
// Observação: propositalmente simples (sem banco) para manter o projeto leve.
type AuthPainel struct {
Senha string
Token string
}
const (
cookiePainelToken = "eli_nps_painel"
cookiePainelOper = "eli_nps_operador"
cookiePainelSessao = "eli_nps_painel_sessao"
)
// 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)
w.Write([]byte("painel desabilitado"))
return
}
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
senha := r.FormValue("senha")
operador := strings.TrimSpace(r.FormValue("operador"))
if subtle.ConstantTimeCompare([]byte(senha), []byte(a.Senha)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("senha invalida"))
return
}
if len(operador) < 2 || len(operador) > 80 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("operador invalido"))
return
}
// Sessão do painel (por login) para aplicar regra: só edita/deleta comentário
// na mesma sessão.
// Observação: diferente do cookie de autenticação do painel (token por boot).
sessaoID := gerarSessaoUUID()
http.SetCookie(w, &http.Cookie{
Name: a.cookieName(),
Value: a.Token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
// Secure deve ser true em produção com HTTPS.
Secure: false,
// Expira em 24h (relogin simples).
Expires: time.Now().Add(24 * time.Hour),
})
// Operador (auditoria de comentários/ações)
http.SetCookie(w, &http.Cookie{
Name: cookiePainelOper,
Value: url.PathEscape(operador),
Path: "/painel",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: false,
Expires: time.Now().Add(180 * 24 * time.Hour),
})
// Sessão por login
http.SetCookie(w, &http.Cookie{
Name: cookiePainelSessao,
Value: sessaoID,
Path: "/painel",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: false,
Expires: time.Now().Add(24 * time.Hour),
})
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 cookiePainelToken }
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 operadorFromCookie(r *http.Request) string {
c, err := r.Cookie(cookiePainelOper)
if err != nil {
return ""
}
v, err := url.PathUnescape(c.Value)
if err != nil {
return ""
}
return strings.TrimSpace(v)
}
func sessaoPainelFromCookie(r *http.Request) string {
c, err := r.Cookie(cookiePainelSessao)
if err != nil {
return ""
}
return strings.TrimSpace(c.Value)
}
func gerarSessaoUUID() string {
// UUID v4 via crypto/rand.
b := make([]byte, 16)
_, _ = crand.Read(b)
// Set version (4) and variant (RFC4122)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
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) {
ctx := r.Context()
// Garante tabelas globais do painel (comentários/status).
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao garantir tabelas do painel"})
return
}
// Query params
produto := r.URL.Query().Get("produto")
pagina := 1
if p := r.URL.Query().Get("pagina"); p != "" {
// best-effort parse
_, _ = fmt.Sscanf(p, "%d", &pagina)
if pagina <= 0 {
pagina = 1
}
}
somenteBaixas := r.URL.Query().Get("baixas") == "1"
somentePendentes := r.URL.Query().Get("pendentes") == "1"
produtos, err := store.ListarProdutos(ctx)
if err != nil {
a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao listar produtos"})
return
}
if produto == "" && len(produtos) > 0 {
produto = produtos[0]
}
operador := operadorFromCookie(r)
sessaoID := sessaoPainelFromCookie(r)
dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas, SomentePendentes: somentePendentes, Operador: operador, SessaoID: sessaoID}
if produto == "" {
a.renderPainelHTML(w, dados)
return
}
// tabela segura
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
dados.MsgErro = "produto inválido"
a.renderPainelHTML(w, dados)
return
}
tabela := db.TableNameForProduto(prodNorm)
if err := db.EnsureNPSTable(ctx, store.poolRef(), tabela); err != nil {
dados.MsgErro = "erro ao garantir tabela"
a.renderPainelHTML(w, dados)
return
}
meses, err := store.NPSMesAMes(ctx, tabela, 12)
if err != nil {
dados.MsgErro = "erro ao calcular NPS"
a.renderPainelHTML(w, dados)
return
}
dados.Meses = meses
respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50})
if err != nil {
if err == pgx.ErrNoRows {
respostas = []contratos.RespostaPainel{}
} else {
dados.MsgErro = "erro ao listar respostas"
}
}
dados.Respostas = respostas
// Enriquecimento: status de análise + comentários internos (em lote).
ids := make([]string, 0, len(respostas))
for _, rr := range respostas {
ids = append(ids, rr.ID)
}
st, err := store.GetStatusAnaliseBatch(ctx, prodNorm, ids)
if err == nil {
dados.StatusAnalise = st
}
coms, err := store.ListarComentariosPainelBatch(ctx, prodNorm, ids)
if err == nil {
dados.Comentarios = coms
}
// Filtro: somente pendentes (após carregar status em lote).
if somentePendentes {
filtradas := make([]contratos.RespostaPainel, 0, len(dados.Respostas))
for _, rr := range dados.Respostas {
st := contratos.StatusAnalisePendente
if dados.StatusAnalise != nil {
if v, ok := dados.StatusAnalise[rr.ID]; ok {
st = v
}
}
if st != contratos.StatusAnaliseConcluida {
filtradas = append(filtradas, rr)
}
}
dados.Respostas = filtradas
}
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()
}
}
}
// ------------------------------
// Ações do painel (comentários/status)
// ------------------------------
func (a AuthPainel) redirectVoltarPainel(w http.ResponseWriter, r *http.Request, produto string) {
// Mantém querystring original (pagina/baixas/etc).
q := r.URL.Query()
q.Set("produto", produto)
u := "/painel?" + q.Encode()
http.Redirect(w, r, u, http.StatusFound)
}
func (a AuthPainel) handlerConcluirResposta(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
produto := chi.URLParam(r, "produto")
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
id := chi.URLParam(r, "id")
operador := operadorFromCookie(r)
if operador == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("operador obrigatorio"))
return
}
_ = operador // reservado: se no futuro quisermos registrar operador no status
if err := store.SetStatusAnalise(ctx, prodNorm, id, StatusAnaliseConcluida); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
a.redirectVoltarPainel(w, r, prodNorm)
}
func (a AuthPainel) handlerReabrirResposta(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
produto := chi.URLParam(r, "produto")
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
id := chi.URLParam(r, "id")
operador := operadorFromCookie(r)
if operador == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("operador obrigatorio"))
return
}
_ = operador
if err := store.SetStatusAnalise(ctx, prodNorm, id, StatusAnalisePendente); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
a.redirectVoltarPainel(w, r, prodNorm)
}
func (a AuthPainel) handlerCriarComentario(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
produto := chi.URLParam(r, "produto")
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
id := chi.URLParam(r, "id")
operador := operadorFromCookie(r)
sessaoID := sessaoPainelFromCookie(r)
if operador == "" || sessaoID == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("operador/sessao obrigatorios"))
return
}
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
comentario := strings.TrimSpace(r.FormValue("comentario"))
if comentario == "" || len(comentario) > 2000 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("comentario invalido"))
return
}
if err := store.CriarComentarioPainel(ctx, prodNorm, id, operador, sessaoID, comentario); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
a.redirectVoltarPainel(w, r, prodNorm)
}
func (a AuthPainel) handlerEditarComentario(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
produto := chi.URLParam(r, "produto")
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
_ = chi.URLParam(r, "id")
comentarioID := chi.URLParam(r, "comentarioID")
sessaoID := sessaoPainelFromCookie(r)
if sessaoID == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("sessao obrigatoria"))
return
}
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
comentario := strings.TrimSpace(r.FormValue("comentario"))
if comentario == "" || len(comentario) > 2000 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("comentario invalido"))
return
}
ok, err := store.EditarComentarioPainel(ctx, comentarioID, sessaoID, comentario)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
if !ok {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("nao permitido"))
return
}
a.redirectVoltarPainel(w, r, prodNorm)
}
func (a AuthPainel) handlerDeletarComentario(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
produto := chi.URLParam(r, "produto")
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
_ = chi.URLParam(r, "id")
comentarioID := chi.URLParam(r, "comentarioID")
sessaoID := sessaoPainelFromCookie(r)
if sessaoID == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("sessao obrigatoria"))
return
}
ok, err := store.DeletarComentarioPainel(ctx, comentarioID, sessaoID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
if !ok {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("nao permitido"))
return
}
a.redirectVoltarPainel(w, r, prodNorm)
}
// handlerComentariosModal renderiza um modal (HTML completo) para gestão de comentários
// de uma resposta específica.
//
// Motivação: manter o painel sem JS e ainda permitir uma UI melhor para comentários.
func (a AuthPainel) handlerComentariosModal(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
if err := db.EnsurePainelTables(ctx, store.poolRef()); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
produto := chi.URLParam(r, "produto")
prodNorm, err := db.NormalizeProduto(produto)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("produto invalido"))
return
}
respostaID := chi.URLParam(r, "id")
comentarios, err := store.ListarComentariosPainel(ctx, prodNorm, respostaID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("db"))
return
}
operador := operadorFromCookie(r)
sessaoID := sessaoPainelFromCookie(r)
// Para voltar ao painel mantendo filtros.
back := "/painel?" + r.URL.Query().Encode()
if !strings.Contains(back, "produto=") {
if strings.Contains(back, "?") {
back += "&produto=" + url.QueryEscape(prodNorm)
} else {
back = "/painel?produto=" + url.QueryEscape(prodNorm)
}
}
formatarDataPainel := 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")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
var b strings.Builder
b.WriteString("")
b.WriteString("")
b.WriteString("Comentários • Painel")
b.WriteString(``)
b.WriteString("")
b.WriteString("")
b.WriteString("
")
b.WriteString("
Comentários internos
")
b.WriteString("
Produto: " + template.HTMLEscapeString(prodNorm) + " • Resposta: " + template.HTMLEscapeString(respostaID) + "
")
if operador != "" {
b.WriteString("
Operador: " + template.HTMLEscapeString(operador) + "
")
}
b.WriteString("
")
b.WriteString("
Fechar")
b.WriteString("
")
// Lista
for _, c := range comentarios {
b.WriteString("
")
b.WriteString("
" + template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + "
")
b.WriteString("
" + template.HTMLEscapeString(c.Comentario) + "
")
if strings.TrimSpace(sessaoID) != "" && c.SessaoID == sessaoID {
// Editar
b.WriteString("
Editar
")
b.WriteString("")
// Deletar
b.WriteString("
")
}
b.WriteString("
")
}
// Criar
if strings.TrimSpace(operador) != "" && strings.TrimSpace(sessaoID) != "" {
b.WriteString("
")
b.WriteString("
Novo comentário
")
b.WriteString("
")
b.WriteString("
")
} else {
b.WriteString("
Faça login novamente informando Operador para comentar.
")
}
b.WriteString("
")
w.Write([]byte(b.String()))
}
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 {
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")
}
isConcluida := func(id string) bool {
if d.StatusAnalise == nil {
return false
}
if st, ok := d.StatusAnalise[id]; ok {
return st == contratos.StatusAnaliseConcluida
}
return false
}
trunc := func(s string, max int) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
if max <= 0 {
return ""
}
if len(s) <= max {
return s
}
return s[:max] + "…"
}
soDigitos := func(s string) string {
return naoDigitosRe.ReplaceAllString(s, "")
}
linkEmail := func(email string) string {
email = strings.TrimSpace(email)
if email == "" {
return "-"
}
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 == "" {
return "", "", template.HTMLEscapeString(telOriginal)
}
hrefTel = "tel:+" + dig
waNum := dig
if (len(waNum) == 10 || len(waNum) == 11) && !strings.HasPrefix(waNum, "55") {
waNum = "55" + waNum
}
hrefWA = "https://wa.me/" + waNum
htmlTel = "" + template.HTMLEscapeString(telOriginal) + ""
return hrefTel, hrefWA, htmlTel
}
var b strings.Builder
b.WriteString("")
b.WriteString("e-li.nps • Painel")
b.WriteString(``)
b.WriteString("")
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 |
")
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("
")
// Respostas
b.WriteString("Respostas
")
if strings.TrimSpace(d.Operador) == "" {
b.WriteString("
Defina um operador (faça login novamente) para comentar e concluir respostas.
")
}
b.WriteString("
| Data | Nota | Pendente? | Usuário | Inquilino | Email | Telefone | Justificativa | Último comentário | Ações |
")
for _, r := range d.Respostas {
data := "-"
if r.RespondidoEm != nil {
data = formatarDataPainel(*r.RespondidoEm)
} else {
data = formatarDataPainel(r.PedidoCriadoEm)
}
nota := "-"
if r.Nota != nil {
nota = fmt.Sprintf("%d", *r.Nota)
}
usuario := template.HTMLEscapeString(r.UsuarioNome)
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)
}
iconMail := ``
iconPhone := ``
iconWA := ``
acoes := ""
if emailHref != "" {
acoes += "" + iconMail + ""
}
if waHref != "" {
acoes += "" + iconWA + ""
}
if telHref != "" {
acoes += "" + iconPhone + ""
}
if acoes == "" {
acoes = "-"
}
justificativa := ""
if r.Justificativa != nil {
justificativa = template.HTMLEscapeString(*r.Justificativa)
}
// Pendente?
pendente := !isConcluida(r.ID)
pendenteTxt := "Sim"
if !pendente {
pendenteTxt = "Não"
}
badgePendente := "" + template.HTMLEscapeString(pendenteTxt) + ""
qBase := "?produto=" + url.QueryEscape(d.Produto)
if d.SomenteBaixas {
qBase += "&baixas=1"
}
if d.SomentePendentes {
qBase += "&pendentes=1"
}
qBase += "&pagina=" + fmt.Sprintf("%d", d.Pagina)
statusCell := badgePendente
// Último comentário (resumo)
comentarios := d.Comentarios[r.ID]
ultimo := "-"
if len(comentarios) > 0 {
c := comentarios[len(comentarios)-1]
ultimo = template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + " — " + template.HTMLEscapeString(trunc(c.Comentario, 90))
}
// Botão ação: abre modal server-side (sem JS)
acaoModal := "-"
if strings.TrimSpace(d.Produto) != "" {
acaoModal = "Comentários"
}
// Botão de ação: alternar status (concluída/pendente)
actionToggle := ""
if strings.TrimSpace(d.Operador) != "" {
if pendente {
actionToggle = ""
} else {
actionToggle = ""
}
}
b.WriteString("| " + template.HTMLEscapeString(data) + " | " + template.HTMLEscapeString(nota) + " | " + statusCell + " | " + usuario + " | " + inquilino + " | " + emailHTML + " | " + telefoneHTML + " | " + justificativa + " | " + ultimo + " | " + acoes + " " + actionToggle + " " + acaoModal + " |
")
}
b.WriteString("
")
// Navegação
base := "/painel?produto=" + url.QueryEscape(d.Produto)
if d.SomenteBaixas {
base += "&baixas=1"
}
if d.SomentePendentes {
base += "&pendentes=1"
}
prev := d.Pagina - 1
if prev < 1 {
prev = 1
}
next := d.Pagina + 1
b.WriteString("
")
b.WriteString("
Anterior")
b.WriteString("
Página " + fmt.Sprintf("%d", d.Pagina) + "")
b.WriteString("
Próxima")
b.WriteString("
")
b.WriteString("
")
// JS do painel: bootstrap do WASM.
b.WriteString(``)
b.WriteString("")
w.Write([]byte(b.String()))
}