adicionado comentários

This commit is contained in:
Luiz Silva 2026-01-05 15:52:44 -03:00
parent e8ca410b94
commit e796b29e1d
8 changed files with 875 additions and 13 deletions

View file

@ -88,13 +88,43 @@ type RespostaPainel struct {
}
type PainelDados struct {
Produto string
Produtos []string
Meses []NPSMensal
Respostas []RespostaPainel
Pagina int
SomenteBaixas bool
MsgErro string
Produto string
Produtos []string
Meses []NPSMensal
Respostas []RespostaPainel
// Operador logado no painel (usado para auditoria de comentários/ações).
Operador string
// ID da sessão do painel (por login). Serve para permitir editar/deletar
// comentários apenas na mesma sessão.
SessaoID string
// Status de análise por resposta_id.
StatusAnalise map[string]StatusAnalisePainel
// Comentários internos por resposta_id.
Comentarios map[string][]ComentarioPainel
Pagina int
SomenteBaixas bool
SomentePendentes bool
MsgErro string
}
// ------------------------------
// Painel: comentários e status de análise
// ------------------------------
type StatusAnalisePainel string
const (
StatusAnalisePendente StatusAnalisePainel = "pendente"
StatusAnaliseConcluida StatusAnalisePainel = "concluida"
)
type ComentarioPainel struct {
ID string
PessoaNome string
SessaoID string
Comentario string
CriadoEm time.Time
AtualizadoEm time.Time
}
type ListarRespostasFiltro struct {

View file

@ -96,6 +96,55 @@ func EnsurePgcrypto(ctx context.Context, pool *pgxpool.Pool) error {
return err
}
// EnsurePainelTables cria as tabelas globais usadas pelo painel.
//
// Motivação:
// - as respostas ficam em tabelas dinâmicas por produto (nps_{produto})
// - precisamos de um lugar único para registrar auditoria de análise (pendente/concluída)
// e comentários internos do painel
//
// Importante:
// - tudo é criado de forma defensiva (IF NOT EXISTS)
// - SQL sempre parametrizado (aqui não há identificadores dinâmicos)
func EnsurePainelTables(ctx context.Context, pool *pgxpool.Pool) error {
// Status de análise por (produto + resposta_id)
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS painel_resposta_status (
produto text NOT NULL,
resposta_id text NOT NULL,
status text NOT NULL CHECK (status IN ('pendente','concluida')),
concluida_em timestamptz NULL,
atualizado_em timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (produto, resposta_id)
)`); err != nil {
return err
}
// Comentários internos por resposta.
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS painel_resposta_comentario (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
produto text NOT NULL,
resposta_id text NOT NULL,
pessoa_nome text NOT NULL,
sessao_id uuid NOT NULL,
comentario text NOT NULL,
criado_em timestamptz NOT NULL DEFAULT now(),
atualizado_em timestamptz NOT NULL DEFAULT now()
)`); err != nil {
return err
}
if _, err := pool.Exec(ctx, `
CREATE INDEX IF NOT EXISTS idx_painel_resp_comentario
ON painel_resposta_comentario (produto, resposta_id, criado_em ASC)
`); err != nil {
return err
}
return nil
}
// EnsureNPSTable cria a tabela por produto + índices se não existirem.
// Importante: tableName deve ser criada a partir de um produto normalizado.
func EnsureNPSTable(ctx context.Context, pool *pgxpool.Pool, tableName string) error {

View file

@ -1,6 +1,7 @@
package elinps
import (
crand "crypto/rand"
"crypto/subtle"
"encoding/csv"
"fmt"
@ -14,6 +15,7 @@ import (
"e-li.nps/internal/contratos"
"e-li.nps/internal/db"
chi "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
@ -28,6 +30,12 @@ type AuthPainel struct {
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`)
@ -42,11 +50,22 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
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(),
@ -60,6 +79,28 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
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)
}
@ -86,6 +127,8 @@ func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) {
<h1>e-li.nps Painel</h1>
<p class="muted">Acesso protegido por senha (SENHA_PAINEL).</p>
<form method="POST" action="/painel/login">
<label>Operador</label>
<input type="text" name="operador" autocomplete="name" />
<label>Senha</label>
<input type="password" name="senha" autocomplete="current-password" />
<button type="submit">Entrar</button>
@ -96,7 +139,7 @@ func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) {
}
func (a AuthPainel) habilitado() bool { return a.Senha != "" && a.Token != "" }
func (a AuthPainel) cookieName() string { return "eli_nps_painel" }
func (a AuthPainel) cookieName() string { return cookiePainelToken }
func (a AuthPainel) isAutenticado(r *http.Request) bool {
c, err := r.Cookie(a.cookieName())
@ -106,6 +149,36 @@ func (a AuthPainel) isAutenticado(r *http.Request) bool {
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() {
@ -128,6 +201,12 @@ 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
@ -139,6 +218,7 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
}
}
somenteBaixas := r.URL.Query().Get("baixas") == "1"
somentePendentes := r.URL.Query().Get("pendentes") == "1"
produtos, err := store.ListarProdutos(ctx)
if err != nil {
@ -149,7 +229,10 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
produto = produtos[0]
}
dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas}
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
@ -187,6 +270,37 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
}
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)
}
@ -330,6 +444,328 @@ func (a AuthPainel) handlerExportCSV(w http.ResponseWriter, r *http.Request, sto
}
}
// ------------------------------
// 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("<!doctype html><html lang=\"pt-br\"><head><meta charset=\"utf-8\"/>")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>")
b.WriteString("<title>Comentários • Painel</title>")
b.WriteString(`<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:rgba(0,0,0,.35);margin:0;padding:18px;}
.modal{max-width:860px;margin:0 auto;background:#fff;border-radius:14px;border:1px solid #e5e5e5;box-shadow:0 12px 30px rgba(0,0,0,.2);padding:14px;}
.top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;}
.badge{display:inline-block;padding:6px 10px;border-radius:999px;background:#f2f2f2;border:1px solid #e5e5e5;font-size:12px;}
.muted{color:#666;font-size:12px;}
textarea{width:100%;min-height:90px;border:1px solid #ddd;border-radius:12px;padding:10px;font-size:14px;}
button{cursor:pointer;}
.btn{padding:8px 12px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;}
.btn2{padding:8px 12px;border-radius:10px;border:1px solid #ddd;background:#fff;color:#111;}
.card{border:1px solid #eee;border-radius:12px;padding:10px;margin-top:10px;}
</style>`)
b.WriteString("</head><body>")
b.WriteString("<div class=\"modal\">")
b.WriteString("<div class=\"top\">")
b.WriteString("<div><h2 style=\"margin:0\">Comentários internos</h2>")
b.WriteString("<div class=\"muted\">Produto: <b>" + template.HTMLEscapeString(prodNorm) + "</b> • Resposta: <b>" + template.HTMLEscapeString(respostaID) + "</b></div>")
if operador != "" {
b.WriteString("<div class=\"muted\">Operador: <b>" + template.HTMLEscapeString(operador) + "</b></div>")
}
b.WriteString("</div>")
b.WriteString("<a class=\"btn2\" href=\"" + template.HTMLEscapeString(back) + "\">Fechar</a>")
b.WriteString("</div>")
// Lista
for _, c := range comentarios {
b.WriteString("<div class=\"card\">")
b.WriteString("<div class=\"muted\" style=\"margin-bottom:8px\">" + template.HTMLEscapeString(c.PessoaNome) + " • " + template.HTMLEscapeString(formatarDataPainel(c.CriadoEm)) + "</div>")
b.WriteString("<div style=\"white-space:pre-wrap\">" + template.HTMLEscapeString(c.Comentario) + "</div>")
if strings.TrimSpace(sessaoID) != "" && c.SessaoID == sessaoID {
// Editar
b.WriteString("<details style=\"margin-top:10px\"><summary class=\"muted\">Editar</summary>")
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios/" + url.PathEscape(c.ID) + "/editar?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
b.WriteString("<textarea name=\"comentario\">" + template.HTMLEscapeString(c.Comentario) + "</textarea>")
b.WriteString("<div style=\"display:flex;gap:8px;margin-top:8px\"><button class=\"btn\" type=\"submit\">Salvar</button></div>")
b.WriteString("</form></details>")
// Deletar
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios/" + url.PathEscape(c.ID) + "/deletar?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
b.WriteString("<button class=\"btn2\" type=\"submit\">Excluir</button>")
b.WriteString("</form>")
}
b.WriteString("</div>")
}
// Criar
if strings.TrimSpace(operador) != "" && strings.TrimSpace(sessaoID) != "" {
b.WriteString("<div class=\"card\">")
b.WriteString("<div class=\"muted\">Novo comentário</div>")
b.WriteString("<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(prodNorm) + "/" + url.PathEscape(respostaID) + "/comentarios?" + r.URL.Query().Encode() + "\" style=\"margin-top:8px\">")
b.WriteString("<textarea name=\"comentario\" placeholder=\"Registrar ação/observação...\"></textarea>")
b.WriteString("<div style=\"display:flex;gap:8px;margin-top:8px\"><button class=\"btn\" type=\"submit\">Adicionar</button><a class=\"btn2\" href=\"" + template.HTMLEscapeString(back) + "\">Voltar</a></div>")
b.WriteString("</form>")
b.WriteString("</div>")
} else {
b.WriteString("<p class=\"badge\" style=\"margin-top:12px\">Faça login novamente informando Operador para comentar.</p>")
}
b.WriteString("</div></body></html>")
w.Write([]byte(b.String()))
}
func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -341,6 +777,32 @@ func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) {
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, "")
}
@ -407,6 +869,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
if d.MsgErro != "" {
b.WriteString("<p class=\"badge\">" + template.HTMLEscapeString(d.MsgErro) + "</p>")
}
if strings.TrimSpace(d.Operador) != "" {
b.WriteString("<p class=\"muted\" style=\"margin:0 0 8px\">Operador: <b>" + template.HTMLEscapeString(d.Operador) + "</b></p>")
}
b.WriteString("<form method=\"GET\" action=\"/painel\" style=\"display:flex;gap:10px;flex-wrap:wrap;align-items:center\">")
b.WriteString("<label class=\"muted\">Produto</label>")
b.WriteString("<select name=\"produto\">")
@ -423,6 +888,11 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
chk = "checked"
}
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</label>")
chkPend := ""
if d.SomentePendentes {
chkPend = "checked"
}
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"pendentes\" value=\"1\" " + chkPend + "/> somente pendentes</label>")
b.WriteString("<button type=\"submit\" style=\"padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer\">Aplicar</button>")
// Export CSV (mantém filtros atuais)
@ -430,6 +900,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
if d.SomenteBaixas {
exportURL += "&baixas=1"
}
if d.SomentePendentes {
exportURL += "&pendentes=1"
}
b.WriteString("<a class=\"badge\" style=\"text-decoration:none;display:inline-block;padding:10px 14px\" href=\"" + exportURL + "\">Exportar CSV</a>")
b.WriteString("</form></div>")
@ -446,7 +919,10 @@ 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("<div class=\"tablewrap\"><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>")
if strings.TrimSpace(d.Operador) == "" {
b.WriteString("<p class=\"badge\">Defina um operador (faça login novamente) para comentar e concluir respostas.</p>")
}
b.WriteString("<div class=\"tablewrap\"><table><thead><tr><th>Data</th><th>Nota</th><th>Pendente?</th><th>Usuário</th><th>Inquilino</th><th>Email</th><th>Telefone</th><th>Justificativa</th><th>Último comentário</th><th>Ações</th></tr></thead><tbody>")
for _, r := range d.Respostas {
data := "-"
if r.RespondidoEm != nil {
@ -496,11 +972,53 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
acoes = "-"
}
coment := ""
justificativa := ""
if r.Justificativa != nil {
coment = template.HTMLEscapeString(*r.Justificativa)
justificativa = template.HTMLEscapeString(*r.Justificativa)
}
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>")
// Pendente?
pendente := !isConcluida(r.ID)
pendenteTxt := "Sim"
if !pendente {
pendenteTxt = "Não"
}
badgePendente := "<span class=\"badge\">" + template.HTMLEscapeString(pendenteTxt) + "</span>"
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 = "<a class=\"badge\" style=\"text-decoration:none\" href=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/comentarios" + qBase + "\">Comentários</a>"
}
// Botão de ação: alternar status (concluída/pendente)
actionToggle := ""
if strings.TrimSpace(d.Operador) != "" {
if pendente {
actionToggle = "<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/concluir" + qBase + "\" style=\"display:inline\"><button class=\"badge\" type=\"submit\" style=\"cursor:pointer\">Concluir</button></form>"
} else {
actionToggle = "<form method=\"POST\" action=\"/painel/respostas/" + url.PathEscape(d.Produto) + "/" + url.PathEscape(r.ID) + "/reabrir" + qBase + "\" style=\"display:inline\"><button class=\"badge\" type=\"submit\" style=\"cursor:pointer\">Reabrir</button></form>"
}
}
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + statusCell + "</td><td>" + usuario + "</td><td>" + inquilino + "</td><td>" + emailHTML + "</td><td>" + telefoneHTML + "</td><td>" + justificativa + "</td><td>" + ultimo + "</td><td>" + acoes + " " + actionToggle + " " + acaoModal + "</td></tr>")
}
b.WriteString("</tbody></table></div>")
@ -509,6 +1027,9 @@ th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:to
if d.SomenteBaixas {
base += "&baixas=1"
}
if d.SomentePendentes {
base += "&pendentes=1"
}
prev := d.Pagina - 1
if prev < 1 {
prev = 1

View file

@ -40,6 +40,26 @@ func (p *PainelHandlers) Router() http.Handler {
p.auth.handlerPainel(w, r, p.store)
})
// Ações do painel (status + comentários)
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/concluir", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerConcluirResposta(w, r, p.store)
})
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/reabrir", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerReabrirResposta(w, r, p.store)
})
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/comentarios", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerCriarComentario(w, r, p.store)
})
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/comentarios/{comentarioID}/editar", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerEditarComentario(w, r, p.store)
})
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Post("/respostas/{produto}/{id}/comentarios/{comentarioID}/deletar", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerDeletarComentario(w, r, p.store)
})
r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/respostas/{produto}/{id}/comentarios", func(w http.ResponseWriter, r *http.Request) {
p.auth.handlerComentariosModal(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) {

View file

@ -22,6 +22,192 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) poolRef() *pgxpool.Pool { return s.pool }
// ------------------------------
// Painel: comentários e status de análise
// ------------------------------
type StatusAnalisePainel = contratos.StatusAnalisePainel
const (
StatusAnalisePendente = contratos.StatusAnalisePendente
StatusAnaliseConcluida = contratos.StatusAnaliseConcluida
)
type ComentarioPainel = contratos.ComentarioPainel
// GetStatusAnalise retorna o status do painel para uma resposta.
// Se não existir registro, considera "pendente".
func (s *Store) GetStatusAnalise(ctx context.Context, produto, respostaID string) (StatusAnalisePainel, error) {
var status string
err := s.pool.QueryRow(ctx, `
SELECT status
FROM painel_resposta_status
WHERE produto=$1 AND resposta_id=$2
`, produto, respostaID).Scan(&status)
if err == pgx.ErrNoRows {
return StatusAnalisePendente, nil
}
if err != nil {
return "", err
}
switch status {
case string(StatusAnalisePendente):
return StatusAnalisePendente, nil
case string(StatusAnaliseConcluida):
return StatusAnaliseConcluida, nil
default:
// fallback defensivo
return StatusAnalisePendente, nil
}
}
func (s *Store) SetStatusAnalise(ctx context.Context, produto, respostaID string, status StatusAnalisePainel) error {
if status != StatusAnalisePendente && status != StatusAnaliseConcluida {
return fmt.Errorf("status invalido")
}
concluidaEm := (*time.Time)(nil)
if status == StatusAnaliseConcluida {
agora := time.Now()
concluidaEm = &agora
}
_, err := s.pool.Exec(ctx, `
INSERT INTO painel_resposta_status (produto, resposta_id, status, concluida_em, atualizado_em)
VALUES ($1,$2,$3,$4, now())
ON CONFLICT (produto, resposta_id)
DO UPDATE SET
status=EXCLUDED.status,
concluida_em=EXCLUDED.concluida_em,
atualizado_em=now()
`, produto, respostaID, string(status), concluidaEmOrNil(concluidaEm))
return err
}
func concluidaEmOrNil(t *time.Time) any {
if t == nil {
return nil
}
return *t
}
func (s *Store) ListarComentariosPainel(ctx context.Context, produto, respostaID string) ([]ComentarioPainel, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, pessoa_nome, sessao_id::text, comentario, criado_em, atualizado_em
FROM painel_resposta_comentario
WHERE produto=$1 AND resposta_id=$2
ORDER BY criado_em ASC
`, produto, respostaID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ComentarioPainel{}
for rows.Next() {
var c ComentarioPainel
if err := rows.Scan(&c.ID, &c.PessoaNome, &c.SessaoID, &c.Comentario, &c.CriadoEm, &c.AtualizadoEm); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) CriarComentarioPainel(ctx context.Context, produto, respostaID, pessoaNome, sessaoID, comentario string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO painel_resposta_comentario (produto, resposta_id, pessoa_nome, sessao_id, comentario, criado_em, atualizado_em)
VALUES ($1,$2,$3,$4::uuid,$5, now(), now())
`, produto, respostaID, pessoaNome, sessaoID, comentario)
return err
}
// EditarComentarioPainel edita um comentário, mas somente se pertencer à mesma sessão.
func (s *Store) EditarComentarioPainel(ctx context.Context, comentarioID, sessaoID, comentario string) (bool, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE painel_resposta_comentario
SET comentario=$3, atualizado_em=now()
WHERE id=$1::uuid AND sessao_id=$2::uuid
`, comentarioID, sessaoID, comentario)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// DeletarComentarioPainel remove um comentário, mas somente se pertencer à mesma sessão.
func (s *Store) DeletarComentarioPainel(ctx context.Context, comentarioID, sessaoID string) (bool, error) {
tag, err := s.pool.Exec(ctx, `
DELETE FROM painel_resposta_comentario
WHERE id=$1::uuid AND sessao_id=$2::uuid
`, comentarioID, sessaoID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// GetStatusAnaliseBatch retorna um mapa resposta_id -> status.
// Se não existir registro para um id, ele simplesmente não aparece no mapa.
func (s *Store) GetStatusAnaliseBatch(ctx context.Context, produto string, respostaIDs []string) (map[string]StatusAnalisePainel, error) {
out := map[string]StatusAnalisePainel{}
if len(respostaIDs) == 0 {
return out, nil
}
rows, err := s.pool.Query(ctx, `
SELECT resposta_id, status
FROM painel_resposta_status
WHERE produto=$1 AND resposta_id = ANY($2::text[])
`, produto, respostaIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id, st string
if err := rows.Scan(&id, &st); err != nil {
return nil, err
}
switch st {
case string(StatusAnaliseConcluida):
out[id] = StatusAnaliseConcluida
default:
out[id] = StatusAnalisePendente
}
}
return out, rows.Err()
}
// ListarComentariosPainelBatch retorna um mapa resposta_id -> comentários.
func (s *Store) ListarComentariosPainelBatch(ctx context.Context, produto string, respostaIDs []string) (map[string][]ComentarioPainel, error) {
out := map[string][]ComentarioPainel{}
if len(respostaIDs) == 0 {
return out, nil
}
rows, err := s.pool.Query(ctx, `
SELECT resposta_id, id::text, pessoa_nome, sessao_id::text, comentario, criado_em, atualizado_em
FROM painel_resposta_comentario
WHERE produto=$1 AND resposta_id = ANY($2::text[])
ORDER BY resposta_id ASC, criado_em ASC
`, produto, respostaIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var respostaID string
var c ComentarioPainel
if err := rows.Scan(&respostaID, &c.ID, &c.PessoaNome, &c.SessaoID, &c.Comentario, &c.CriadoEm, &c.AtualizadoEm); err != nil {
return nil, err
}
out[respostaID] = append(out[respostaID], c)
}
return out, rows.Err()
}
func ipReal(r *http.Request) string {
// IP real do cliente.
//