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("
") b.WriteString("") b.WriteString("
") b.WriteString("
") // Deletar b.WriteString("
") b.WriteString("") b.WriteString("
") } b.WriteString("
") } // Criar if strings.TrimSpace(operador) != "" && strings.TrimSpace(sessaoID) != "" { b.WriteString("
") b.WriteString("
Novo comentário
") b.WriteString("
") b.WriteString("") b.WriteString("
Voltar
") 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("

e-li.nps • Painel

") if d.MsgErro != "" { b.WriteString("

" + template.HTMLEscapeString(d.MsgErro) + "

") } if strings.TrimSpace(d.Operador) != "" { b.WriteString("

Operador: " + template.HTMLEscapeString(d.Operador) + "

") } b.WriteString("
") b.WriteString("") b.WriteString("") chk := "" if d.SomenteBaixas { chk = "checked" } b.WriteString("") chkPend := "" if d.SomentePendentes { chkPend = "checked" } b.WriteString("") b.WriteString("") // Export CSV (mantém filtros atuais) exportURL := "/painel/export.csv?produto=" + url.QueryEscape(d.Produto) if d.SomenteBaixas { exportURL += "&baixas=1" } if d.SomentePendentes { exportURL += "&pendentes=1" } b.WriteString("Exportar CSV") b.WriteString("
") b.WriteString("
") // NPS mês a mês b.WriteString("

NPS mês a mês

") b.WriteString("
") for _, m := range d.Meses { b.WriteString(fmt.Sprintf("", template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS)) } b.WriteString("
MêsDetratoresNeutrosPromotoresTotalNPS
%s%d%d%d%d%d
") // 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("
") 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("") } b.WriteString("
DataNotaPendente?UsuárioInquilinoEmailTelefoneJustificativaÚltimo comentárioAções
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + statusCell + "" + usuario + "" + inquilino + "" + emailHTML + "" + telefoneHTML + "" + justificativa + "" + ultimo + "" + acoes + " " + actionToggle + " " + acaoModal + "
") // 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())) }