adicionado 0 na escal de nota
This commit is contained in:
parent
0bbd04ee45
commit
e8ca410b94
8 changed files with 72 additions and 15 deletions
|
|
@ -380,6 +380,8 @@ Abre o formulário (HTML) para responder/editar.
|
||||||
|
|
||||||
### `PATCH /api/e-li.nps/{produto}/{id}`
|
### `PATCH /api/e-li.nps/{produto}/{id}`
|
||||||
|
|
||||||
|
Regra de negócio: a nota (NPS) vai de **0 até 10**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X PATCH http://localhost:8080/api/e-li.nps/elicencie_gov/<id> \
|
curl -sS -X PATCH http://localhost:8080/api/e-li.nps/elicencie_gov/<id> \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,8 @@ CREATE TABLE IF NOT EXISTS %s (
|
||||||
pedido_criado_em timestamptz NOT NULL DEFAULT now(),
|
pedido_criado_em timestamptz NOT NULL DEFAULT now(),
|
||||||
respondido_em timestamptz NULL,
|
respondido_em timestamptz NULL,
|
||||||
atualizado_em timestamptz NOT NULL DEFAULT now(),
|
atualizado_em timestamptz NOT NULL DEFAULT now(),
|
||||||
nota int NULL CHECK (nota BETWEEN 1 AND 10),
|
-- Escala NPS do projeto: 0–10.
|
||||||
|
nota int NULL CHECK (nota BETWEEN 0 AND 10),
|
||||||
justificativa text NULL,
|
justificativa text NULL,
|
||||||
valida bool NOT NULL DEFAULT true,
|
valida bool NOT NULL DEFAULT true,
|
||||||
origem text NOT NULL DEFAULT 'widget_iframe',
|
origem text NOT NULL DEFAULT 'widget_iframe',
|
||||||
|
|
@ -135,6 +136,43 @@ ALTER TABLE %s ADD COLUMN IF NOT EXISTS usuario_codigo text;
|
||||||
ALTER TABLE %s ADD COLUMN IF NOT EXISTS produto_nome text NOT NULL DEFAULT '';
|
ALTER TABLE %s ADD COLUMN IF NOT EXISTS produto_nome text NOT NULL DEFAULT '';
|
||||||
ALTER TABLE %s ADD COLUMN IF NOT EXISTS ip_real text;
|
ALTER TABLE %s ADD COLUMN IF NOT EXISTS ip_real text;
|
||||||
|
|
||||||
|
-- Migração defensiva (em runtime) da constraint de nota.
|
||||||
|
--
|
||||||
|
-- Motivação:
|
||||||
|
-- - Em versões anteriores a escala era 1–10.
|
||||||
|
-- - As tabelas por produto são criadas automaticamente; portanto, podem existir
|
||||||
|
-- tabelas antigas com CHECK antigo em nota.
|
||||||
|
--
|
||||||
|
-- Estratégia:
|
||||||
|
-- - Remover qualquer CHECK existente que mencione a coluna nota.
|
||||||
|
-- - Recriar uma constraint nomeada ck_nota_0_10 com a regra atual (0–10).
|
||||||
|
--
|
||||||
|
-- Segurança: tableName é validado por TableNameValido (regex) antes de ser
|
||||||
|
-- interpolado e usado como regclass/identificador.
|
||||||
|
DO $$
|
||||||
|
DECLARE c record;
|
||||||
|
BEGIN
|
||||||
|
FOR c IN
|
||||||
|
SELECT conname
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = '%s'::regclass
|
||||||
|
AND contype='c'
|
||||||
|
AND pg_get_constraintdef(oid) ILIKE '%%nota%%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %%I DROP CONSTRAINT %%I', '%s', c.conname);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = '%s'::regclass
|
||||||
|
AND contype='c'
|
||||||
|
AND conname='ck_nota_0_10'
|
||||||
|
) THEN
|
||||||
|
EXECUTE format('ALTER TABLE %%I ADD CONSTRAINT ck_nota_0_10 CHECK (nota BETWEEN 0 AND 10)', '%s');
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
-- NOTE: controle de exibição é por (produto + inquilino_codigo + usuario_codigo)
|
-- NOTE: controle de exibição é por (produto + inquilino_codigo + usuario_codigo)
|
||||||
-- então os índices são baseados em usuario_codigo.
|
-- então os índices são baseados em usuario_codigo.
|
||||||
|
|
||||||
|
|
@ -145,7 +183,20 @@ CREATE INDEX IF NOT EXISTS idx_nps_resp_recente_%s
|
||||||
CREATE INDEX IF NOT EXISTS idx_nps_pedido_aberto_%s
|
CREATE INDEX IF NOT EXISTS idx_nps_pedido_aberto_%s
|
||||||
ON %s (inquilino_codigo, usuario_codigo, pedido_criado_em DESC)
|
ON %s (inquilino_codigo, usuario_codigo, pedido_criado_em DESC)
|
||||||
WHERE status='pedido';
|
WHERE status='pedido';
|
||||||
`, tableName, tableName, tableName, tableName, tableName, tableName, tableName, tableName)
|
`,
|
||||||
|
tableName, // CREATE TABLE
|
||||||
|
tableName, // ALTER TABLE add usuario_codigo
|
||||||
|
tableName, // ALTER TABLE add produto_nome
|
||||||
|
tableName, // ALTER TABLE add ip_real
|
||||||
|
tableName, // DO block: conrelid (1)
|
||||||
|
tableName, // DO block: DROP CONSTRAINT (identificador)
|
||||||
|
tableName, // DO block: conrelid (2)
|
||||||
|
tableName, // DO block: ADD CONSTRAINT (identificador)
|
||||||
|
tableName, // idx_nps_resp_recente_%s
|
||||||
|
tableName, // ON %s
|
||||||
|
tableName, // idx_nps_pedido_aberto_%s
|
||||||
|
tableName, // ON %s
|
||||||
|
)
|
||||||
|
|
||||||
_, err := pool.Exec(ctx, q)
|
_, err := pool.Exec(ctx, q)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ ORDER BY tablename`)
|
||||||
|
|
||||||
// NPSMesAMes calcula o NPS por mês para um produto (tabela `nps_{produto}`).
|
// NPSMesAMes calcula o NPS por mês para um produto (tabela `nps_{produto}`).
|
||||||
//
|
//
|
||||||
// Regra NPS (1–10):
|
// Regra NPS (0–10):
|
||||||
// - 1–6 detratores
|
// - 0–6 detratores
|
||||||
// - 7–8 neutros
|
// - 7–8 neutros
|
||||||
// - 9–10 promotores
|
// - 9–10 promotores
|
||||||
func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]contratos.NPSMensal, error) {
|
func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]contratos.NPSMensal, error) {
|
||||||
|
|
@ -62,7 +62,7 @@ WITH base AS (
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
to_char(mes, 'YYYY-MM') AS mes,
|
to_char(mes, 'YYYY-MM') AS mes,
|
||||||
SUM(CASE WHEN nota BETWEEN 1 AND 6 THEN 1 ELSE 0 END)::int AS detratores,
|
SUM(CASE WHEN nota BETWEEN 0 AND 6 THEN 1 ELSE 0 END)::int AS detratores,
|
||||||
SUM(CASE WHEN nota BETWEEN 7 AND 8 THEN 1 ELSE 0 END)::int AS neutros,
|
SUM(CASE WHEN nota BETWEEN 7 AND 8 THEN 1 ELSE 0 END)::int AS neutros,
|
||||||
SUM(CASE WHEN nota BETWEEN 9 AND 10 THEN 1 ELSE 0 END)::int AS promotores,
|
SUM(CASE WHEN nota BETWEEN 9 AND 10 THEN 1 ELSE 0 END)::int AS promotores,
|
||||||
COUNT(*)::int AS total
|
COUNT(*)::int AS total
|
||||||
|
|
@ -116,7 +116,7 @@ func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro Lista
|
||||||
|
|
||||||
cond := "status='respondido' AND valida=true"
|
cond := "status='respondido' AND valida=true"
|
||||||
if filtro.SomenteNotasBaixas {
|
if filtro.SomenteNotasBaixas {
|
||||||
cond += " AND nota BETWEEN 1 AND 6"
|
cond += " AND nota BETWEEN 0 AND 6"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Importante (segurança): apesar do cond ser construído em string, ele NÃO usa
|
// Importante (segurança): apesar do cond ser construído em string, ele NÃO usa
|
||||||
|
|
@ -180,7 +180,7 @@ func (s *Store) ExportarRespostas(ctx context.Context, tabela string, filtro Exp
|
||||||
|
|
||||||
cond := "status='respondido' AND valida=true"
|
cond := "status='respondido' AND valida=true"
|
||||||
if filtro.SomenteNotasBaixas {
|
if filtro.SomenteNotasBaixas {
|
||||||
cond += " AND nota BETWEEN 1 AND 6"
|
cond += " AND nota BETWEEN 0 AND 6"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sem LIMIT/OFFSET (export completo).
|
// Sem LIMIT/OFFSET (export completo).
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ func ValidatePedidoInput(in *contratos.PedidoInput) error {
|
||||||
|
|
||||||
func ValidatePatchInput(in *contratos.PatchInput) error {
|
func ValidatePatchInput(in *contratos.PatchInput) error {
|
||||||
if in.Nota != nil {
|
if in.Nota != nil {
|
||||||
if *in.Nota < 1 || *in.Nota > 10 {
|
// Regra do produto: escala NPS é 0–10.
|
||||||
|
if *in.Nota < 0 || *in.Nota > 10 {
|
||||||
return errors.New("nota invalida")
|
return errors.New("nota invalida")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
-- pedido_criado_em timestamptz NOT NULL DEFAULT now(),
|
-- pedido_criado_em timestamptz NOT NULL DEFAULT now(),
|
||||||
-- respondido_em timestamptz NULL,
|
-- respondido_em timestamptz NULL,
|
||||||
-- atualizado_em timestamptz NOT NULL DEFAULT now(),
|
-- atualizado_em timestamptz NOT NULL DEFAULT now(),
|
||||||
-- nota int NULL CHECK (nota BETWEEN 1 AND 10),
|
-- nota int NULL CHECK (nota BETWEEN 0 AND 10),
|
||||||
-- justificativa text NULL,
|
-- justificativa text NULL,
|
||||||
-- valida bool NOT NULL DEFAULT true,
|
-- valida bool NOT NULL DEFAULT true,
|
||||||
-- origem text NOT NULL DEFAULT 'widget_iframe',
|
-- origem text NOT NULL DEFAULT 'widget_iframe',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
{{template "edit_block.html" .}}
|
{{template "edit_block.html" .}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="eli-nps-title">De 1 a 10, quanto você recomenda {{if .Reg.ProdutoNome}}{{.Reg.ProdutoNome}}{{else}}{{produtoLabel .Produto}}{{end}} para um amigo?</p>
|
<p class="eli-nps-title">De 0 a 10, quanto você recomenda {{if .Reg.ProdutoNome}}{{.Reg.ProdutoNome}}{{else}}{{produtoLabel .Produto}}{{end}} para um amigo?</p>
|
||||||
{{template "nota_block.html" .}}
|
{{template "nota_block.html" .}}
|
||||||
{{if .Reg.Nota}}
|
{{if .Reg.Nota}}
|
||||||
{{template "justificativa_block.html" .}}
|
{{template "justificativa_block.html" .}}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
.eli-nps-card{max-width:520px;margin:0 auto;padding:16px;}
|
.eli-nps-card{max-width:520px;margin:0 auto;padding:16px;}
|
||||||
.eli-nps-title{font-size:18px;font-weight:700;margin:0 0 8px;}
|
.eli-nps-title{font-size:18px;font-weight:700;margin:0 0 8px;}
|
||||||
.eli-nps-sub{margin:0 0 12px;color:#444;font-size:14px;}
|
.eli-nps-sub{margin:0 0 12px;color:#444;font-size:14px;}
|
||||||
.eli-nps-scale{display:grid;grid-template-columns:repeat(10,1fr);gap:8px;margin:12px 0;}
|
.eli-nps-scale{display:grid;grid-template-columns:repeat(11,1fr);gap:8px;margin:12px 0;}
|
||||||
/*
|
/*
|
||||||
Botões de nota (1–10) com escala vermelho → verde.
|
Botões de nota (0–10) com escala vermelho → verde.
|
||||||
Importante: a cor base é determinada pelo valor; quando selecionado,
|
Importante: a cor base é determinada pelo valor; quando selecionado,
|
||||||
o botão ganha destaque (borda/sombra) mas mantém a cor.
|
o botão ganha destaque (borda/sombra) mas mantém a cor.
|
||||||
*/
|
*/
|
||||||
|
|
@ -48,12 +48,15 @@
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Paleta por grupo:
|
Paleta por grupo:
|
||||||
- 1 a 6: tons de vermelho
|
- 0 a 6: tons de vermelho
|
||||||
- 7 e 8: tons de amarelo
|
- 7 e 8: tons de amarelo
|
||||||
- 9 e 10: tons de verde
|
- 9 e 10: tons de verde
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* 1–6 (vermelho) */
|
/* 0–6 (vermelho) */
|
||||||
|
.eli-nps-btn-0{background:#ffebee;border-color:#ffcdd2;}
|
||||||
|
.eli-nps-btn-0:hover,.eli-nps-btn-0.eli-nps-btn-selected{background:#f44336;color:#fff;border-color:#f44336;}
|
||||||
|
|
||||||
.eli-nps-btn-1{background:#ffebee;border-color:#ffcdd2;}
|
.eli-nps-btn-1{background:#ffebee;border-color:#ffcdd2;}
|
||||||
.eli-nps-btn-1:hover,.eli-nps-btn-1.eli-nps-btn-selected{background:#ef5350;color:#fff;border-color:#ef5350;}
|
.eli-nps-btn-1:hover,.eli-nps-btn-1.eli-nps-btn-selected{background:#ef5350;color:#fff;border-color:#ef5350;}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{{define "nota_block.html"}}
|
{{define "nota_block.html"}}
|
||||||
<div class="eli-nps-scale">
|
<div class="eli-nps-scale">
|
||||||
{{range $i := seq 1 10}}
|
{{range $i := seq 0 10}}
|
||||||
<button
|
<button
|
||||||
class="eli-nps-btn eli-nps-btn-{{$i}} {{if noteEq $.Reg.Nota $i}}eli-nps-btn-selected{{end}}"
|
class="eli-nps-btn eli-nps-btn-{{$i}} {{if noteEq $.Reg.Nota $i}}eli-nps-btn-selected{{end}}"
|
||||||
hx-patch="/api/e-li.nps/{{$.Produto}}/{{$.ID}}"
|
hx-patch="/api/e-li.nps/{{$.Produto}}/{{$.ID}}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue