mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
feat(inbox): a demanda no painel de quem atende — passo 4 do cap. 5 fecha
O painel lateral mostrava negócio, pedido e histórico: três listas sobre o que
JÁ aconteceu. Nenhuma responde à pergunta que a pessoa do outro lado está
fazendo — o que ela pediu e ainda não foi resolvido. O caso concreto: o
atendente encerra a conversa, a demanda segue aberta e sem próximo passo, e o
vazamento só reaparece depois como número numa métrica que ele não abre.
Corrijo uma afirmação minha anterior: eu havia escrito que "o inbox lê
`crm_leads`" e procurado em `app/app/inbox/`. Lá não há nada — o inbox é um
redirect. A leitura vive em `crm-summary` e o painel em `CRMSidePanel`. Certo em
espírito, errado de endereço.
## Segui o desenho da rota em vez de contornar
`crm-summary` usa client de SESSÃO (RLS ativa) e tem uma regra explícita: "um
pedido, um veredito", as consultas falham juntas. Isso existe porque o defeito
original era erro de permissão traduzido em "Sem leads.". A 4ª consulta entra no
mesmo Promise.all e no mesmo `??` de falha — se `demandas` falhar, a rota falha
inteira, em vez de a seção nova mentir "nenhuma demanda aberta" sobre um erro.
## O risco era a RLS, e só a tela prova isso
A política de `demandas` chama `fn_user_org_ids()`. Se a leitura de sessão não
passasse, o painel diria "Nenhuma demanda aberta." para sempre — o defeito que a
rota veio curar, reintroduzido por mim. Teste unitário com `apiClient` mockado
não pega.
`tests/sonda-inbox-demandas-tela.ts` — 15/15, exit 0, com a contagem do banco
como régua: 2 demandas semeadas, 2 na tela (1 sem passo destacada + 1 com),
status 200, antes dos negócios, sem scroll horizontal, zero erro de console. E a
fase SOB FALHA: confessa "Não consegui ler", não afirma "Nenhuma demanda",
oferece tentar de novo.
## Sabotagens (previsão antes de rodar)
mesmo data-testid para todas 1 → 2 (o teste da frase também usa)
a frase some, fica só a cor 1 → 1
seção movida para depois dos leads 1 → 1
o flag `erro` não é setado 1 → 1
setDemandas(null) → setDemandas([]) 1 → 0
A última reprovou ZERO e corrigiu meu modelo mental: eu creditava a proteção ao
`setDemandas(null)`, mas quem impede a mentira é o `setErro(true)` — o `null` é
consistência com as irmãs, não a defesa. Mecanismo redundante identificado e
crédito reatribuído.
## Achado PRÉ-EXISTENTE, medido com controle
O painel do inbox NÃO CABE NA TELA: fica 311px fora da viewport em 1280px e
151px em 1440px (cabe só em 1920). `InboxLayout.tsx:164` usa
`xl:grid-cols-[300px_1fr_320px]` e o `xl` dispara em 1280 — a terceira coluna
nasce no ponto exato em que não há espaço. E `scroll_horizontal` é false, então
não há como alcançar o que ficou fora.
Controle que prova não ser meu: a seção CONTATO, anterior a esta branch, está
exatamente tão fora quanto a minha (295px, mesma left/right). É o layout.
## Não medido, e a causa
A extensão que fiz em `tests/sonda-painel-inbox.ts` (incluir "Nenhuma demanda
aberta." entre as frases proibidas sob falha) NÃO foi executada: a fixture dela,
"Ana Souza LGPD E2E", não existe neste banco — medido, 0 contatos. O
comportamento equivalente está provado no unitário (sabotagem do flag, 1→1) e na
tela (fase SOB FALHA), mas a linha daquele arquivo segue sem execução.
typecheck 0 · lint 0 errors · 1813 unitários em 169 arquivos
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkS3mzwtXughmjVC5FCoNo
This commit is contained in:
co-authored by
Claude Opus 5
parent
357a45bd9e
commit
3227bf8136
@@ -38,6 +38,21 @@ const LEAD_COLS = "id, title, status, value_cents, currency, updated_at";
|
||||
const ORDER_COLS = "id, external_id, status, total_cents, currency, created_at";
|
||||
/** Acompanha o que a timeline mostra — `reason` e `actor_kind` inclusive. */
|
||||
const ACTIVITY_COLS = "id, type, source_module, performed_at, payload, reason, actor_kind";
|
||||
/**
|
||||
* Passo 4 do cap. 5 — a DEMANDA chega ao lugar onde o humano atende.
|
||||
*
|
||||
* O painel mostrava negócios, pedidos e histórico. Nenhum dos três responde à
|
||||
* pergunta que a pessoa do outro lado está fazendo: **o que ela pediu e ainda
|
||||
* não foi resolvido.** Lead é o negócio; conversa é o canal; demanda é o que
|
||||
* precisa acabar (doutrina cap. 5).
|
||||
*
|
||||
* O caso concreto que isto evita: o atendente encerra a conversa, a demanda
|
||||
* segue aberta e sem próximo passo, e o vazamento só aparece depois — como
|
||||
* número numa métrica que ele não abre. `proximo_passo` vem junto porque a
|
||||
* ausência dele é o próprio invariante 4, e é o que precisa saltar na tela.
|
||||
*/
|
||||
const DEMANDA_COLS =
|
||||
"id, aberta_em, origem, estado, proximo_passo, proximo_passo_em, prazo_em";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
@@ -55,7 +70,7 @@ export async function GET(
|
||||
return fail("unauthenticated", "Auth required.", 401, { requestId });
|
||||
}
|
||||
|
||||
const [leads, orders, activities] = await Promise.all([
|
||||
const [leads, orders, activities, demandas] = await Promise.all([
|
||||
supabase
|
||||
.from("crm_leads")
|
||||
.select(LEAD_COLS)
|
||||
@@ -74,11 +89,22 @@ export async function GET(
|
||||
.eq("contact_id", contactId)
|
||||
.order("performed_at", { ascending: false })
|
||||
.limit(5),
|
||||
// Só as ABERTAS: demanda encerrada é histórico e já vive na timeline. Da
|
||||
// mais antiga para a mais nova — quem espera há mais tempo aparece primeiro,
|
||||
// mesma régua do Radar, para as duas telas não contarem histórias
|
||||
// diferentes sobre o mesmo contato.
|
||||
supabase
|
||||
.from("demandas")
|
||||
.select(DEMANDA_COLS)
|
||||
.eq("contact_id", contactId)
|
||||
.is("fechada_em", null)
|
||||
.order("aberta_em", { ascending: true })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
// A falha SOBE. Engolir aqui devolveria lista vazia ao cliente e recriaria,
|
||||
// do lado do servidor, exatamente a mentira que esta rota veio desfazer.
|
||||
const falha = leads.error ?? orders.error ?? activities.error;
|
||||
const falha = leads.error ?? orders.error ?? activities.error ?? demandas.error;
|
||||
if (falha) {
|
||||
return fail("internal_error", falha.message, 500, { requestId });
|
||||
}
|
||||
@@ -88,6 +114,7 @@ export async function GET(
|
||||
leads: leads.data ?? [],
|
||||
orders: orders.data ?? [],
|
||||
activities: activities.data ?? [],
|
||||
demandas: demandas.data ?? [],
|
||||
},
|
||||
{ requestId },
|
||||
);
|
||||
|
||||
@@ -52,6 +52,36 @@ interface ActivityRow {
|
||||
actor_kind: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passo 4 do cap. 5 — a demanda no lugar onde o humano atende.
|
||||
*
|
||||
* As outras três listas contam o que já aconteceu (negócio, pedido, histórico).
|
||||
* Esta conta o que **ainda não acabou**, que é a pergunta que a pessoa do outro
|
||||
* lado está fazendo. Sem ela, o atendente encerra a conversa sem saber que a
|
||||
* demanda continua aberta e sem próximo passo — e o vazamento só reaparece
|
||||
* depois, como número numa métrica que ele não abre.
|
||||
*/
|
||||
interface DemandaRow {
|
||||
id: string;
|
||||
aberta_em: string;
|
||||
origem: string;
|
||||
estado: string;
|
||||
proximo_passo: string | null;
|
||||
proximo_passo_em: string | null;
|
||||
prazo_em: string | null;
|
||||
}
|
||||
|
||||
/** Vocabulário de quem atende, não o do banco. */
|
||||
const ESTADO_LEGIVEL: Record<string, string> = {
|
||||
aberta: "Aberta",
|
||||
em_atendimento: "Em atendimento",
|
||||
aguardando_cliente: "Aguardando o cliente",
|
||||
};
|
||||
|
||||
function horasDesde(iso: string): number {
|
||||
return Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 3_600_000));
|
||||
}
|
||||
|
||||
function formatMoney(cents: number | null, currency: string | null): string {
|
||||
if (cents == null) return "—";
|
||||
const cur = currency ?? "BRL";
|
||||
@@ -107,6 +137,7 @@ export function CRMSidePanel({ conversation }: Props) {
|
||||
const [leads, setLeads] = useState<LeadRow[] | null>(null);
|
||||
const [orders, setOrders] = useState<OrderRow[] | null>(null);
|
||||
const [activities, setActivities] = useState<ActivityRow[] | null>(null);
|
||||
const [demandas, setDemandas] = useState<DemandaRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
/**
|
||||
* O TERCEIRO ESTADO. Antes existiam dois — carregando e "tem N itens" — e a
|
||||
@@ -133,6 +164,7 @@ export function CRMSidePanel({ conversation }: Props) {
|
||||
setLeads(null);
|
||||
setOrders(null);
|
||||
setActivities(null);
|
||||
setDemandas(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -145,12 +177,22 @@ export function CRMSidePanel({ conversation }: Props) {
|
||||
async function load() {
|
||||
try {
|
||||
const r = await apiClient.get<{
|
||||
data: { leads: LeadRow[]; orders: OrderRow[]; activities: ActivityRow[] };
|
||||
data: {
|
||||
leads: LeadRow[];
|
||||
orders: OrderRow[];
|
||||
activities: ActivityRow[];
|
||||
demandas: DemandaRow[];
|
||||
};
|
||||
}>(`/api/v1/contacts/${contactId}/crm-summary`);
|
||||
if (cancelled) return;
|
||||
setLeads(r.data.leads);
|
||||
setOrders(r.data.orders);
|
||||
setActivities(r.data.activities);
|
||||
// `?? []` e não `?? null`: aqui a leitura DEU CERTO. Cair em `null`
|
||||
// faria a lista vazia se disfarçar do terceiro estado e o painel
|
||||
// mostraria esqueleto para sempre num contato sem demanda aberta —
|
||||
// que é o caso saudável.
|
||||
setDemandas(r.data.demandas ?? []);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
// Falha NÃO vira lista vazia. Os dados ficam `null` e o painel diz que
|
||||
@@ -159,6 +201,7 @@ export function CRMSidePanel({ conversation }: Props) {
|
||||
setLeads(null);
|
||||
setOrders(null);
|
||||
setActivities(null);
|
||||
setDemandas(null);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -183,8 +226,10 @@ export function CRMSidePanel({ conversation }: Props) {
|
||||
// apareceria — o mesmo colapso de significados que criou o defeito original,
|
||||
// só que trocando "erro→vazio" por "erro→carregando".
|
||||
const sectionsLoading = useMemo(
|
||||
() => !erro && (loading || (leads === null && orders === null && activities === null)),
|
||||
[erro, loading, leads, orders, activities],
|
||||
() =>
|
||||
!erro &&
|
||||
(loading || (leads === null && orders === null && activities === null && demandas === null)),
|
||||
[erro, loading, leads, orders, activities, demandas],
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
@@ -269,6 +314,57 @@ export function CRMSidePanel({ conversation }: Props) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ANTES dos negócios de propósito (doutrina cap. 5): lead é o negócio,
|
||||
conversa é o canal, demanda é o que precisa acabar. Quem abre esta
|
||||
conversa está atendendo alguém que pediu alguma coisa — a primeira
|
||||
pergunta a responder é o que ainda está pendente, não quanto vale. */}
|
||||
<section data-testid="inbox-demandas">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Demandas abertas
|
||||
</h3>
|
||||
{sectionsLoading ? (
|
||||
<Skeleton className="mt-2 h-14 w-full" />
|
||||
) : demandas && demandas.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{demandas.map((d) => {
|
||||
const semPasso = !d.proximo_passo;
|
||||
return (
|
||||
<li
|
||||
key={d.id}
|
||||
data-testid={semPasso ? "demanda-sem-proximo-passo" : "demanda-com-proximo-passo"}
|
||||
className={cn(
|
||||
"rounded-md border p-2 text-xs",
|
||||
semPasso ? "border-warning-border bg-warning-bg/40" : "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{ESTADO_LEGIVEL[d.estado] ?? d.estado}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
há {horasDesde(d.aberta_em)}h
|
||||
</span>
|
||||
</div>
|
||||
{/* O invariante 4 na frase, não só na cor: quem enxerga mal
|
||||
cor precisa ler a mesma informação. */}
|
||||
<div className={cn("mt-0.5", semPasso ? "font-medium" : "text-muted-foreground")}>
|
||||
{d.proximo_passo ?? "Sem próximo passo definido"}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<SemLista
|
||||
vazio="Nenhuma demanda aberta."
|
||||
erro={erro}
|
||||
onTentarDeNovo={() => setTentativa((n) => n + 1)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Leads recentes
|
||||
|
||||
@@ -475,12 +475,115 @@ jargao: [] scroll_horizontal: false erros de console: nenhum
|
||||
**editor legado sem `ToolPicker`** (`page.tsx:53`). A sonda fixa um `mcp_agent`
|
||||
e o comentário explica por quê, para o próximo não perder o mesmo tempo.
|
||||
|
||||
### ⚠️ PENDENTE
|
||||
---
|
||||
|
||||
- **Inbox** segue lendo `crm_leads`. Enquanto isso durar, a conversa ainda é
|
||||
tratada como unidade em parte do sistema — que é o que o passo 4 existe para
|
||||
terminar.
|
||||
- **A `description` morta do catálogo** (acima) — decisão de desenho pendente.
|
||||
## Passo 4 do cap. 5 — o inbox, e com ele o passo FECHA (2026-08-07)
|
||||
|
||||
O painel lateral do inbox mostrava negócio, pedido e histórico: três listas
|
||||
sobre o que **já aconteceu**. Nenhuma responde à pergunta que a pessoa do outro
|
||||
lado está fazendo — *o que eu pedi e ainda não foi resolvido*. O caso concreto:
|
||||
o atendente encerra a conversa, a demanda segue aberta e sem próximo passo, e o
|
||||
vazamento só reaparece depois como número numa métrica que ele não abre.
|
||||
|
||||
**Correção da afirmação anterior deste handoff:** eu havia escrito "o inbox
|
||||
segue lendo `crm_leads`" e procurado em `app/app/inbox/`. Lá não há nada — o
|
||||
inbox é só um redirect. A leitura vive em `app/api/v1/contacts/[id]/crm-summary`
|
||||
e o painel em `components/inbox/CRMSidePanel.tsx`. A afirmação estava certa em
|
||||
espírito e errada de endereço.
|
||||
|
||||
### O desenho da rota, que eu segui em vez de contornar
|
||||
|
||||
`crm-summary` usa **client de sessão** (RLS ativa), não service role — e tem uma
|
||||
regra explícita: *"um pedido, um veredito"*, as consultas falham juntas. Isso
|
||||
existe porque o defeito original era erro de permissão traduzido em `Sem leads.`
|
||||
A 4ª consulta entra no mesmo `Promise.all` e no mesmo `??` de falha. Se
|
||||
`demandas` falhar, a rota falha inteira — em vez de a seção nova dizer "nenhuma
|
||||
demanda aberta" em cima de um erro.
|
||||
|
||||
### O risco real era a RLS, e por isso a prova de tela é a que vale
|
||||
|
||||
A política de `demandas` chama `fn_user_org_ids()`. Se a leitura de sessão não
|
||||
passasse, o painel diria "Nenhuma demanda aberta." para sempre — o defeito que a
|
||||
rota veio curar, reintroduzido por mim numa seção nova. Um teste unitário com
|
||||
`apiClient` mockado **não pega isso**.
|
||||
|
||||
`tests/sonda-inbox-demandas-tela.ts` — 15/15, exit 0, com 2 demandas semeadas no
|
||||
banco (uma sem próximo passo) e a contagem do banco como régua:
|
||||
|
||||
```
|
||||
texto na tela: "DEMANDAS ABERTAS
|
||||
Aberta · há 9h · Sem próximo passo definido ← destacada
|
||||
Em atendimento · há 3h · Enviar o orçamento revisado"
|
||||
noBanco: 2 itens na tela: 1 sem passo + 1 com passo = 2 ← bate
|
||||
statusSummary: 200 · a RLS deixou ler · antes dos negócios: true
|
||||
scroll horizontal: false · erros de console: nenhum
|
||||
|
||||
SOB FALHA (rota interceptada com 500):
|
||||
confessa "Não consegui ler": true · mente "Nenhuma demanda": false
|
||||
oferece "Tentar de novo": true
|
||||
```
|
||||
|
||||
### Sabotagens — previsão antes de rodar
|
||||
|
||||
| Sabotagem | Previsão | Resultado | |
|
||||
|---|---|---|---|
|
||||
| Todas as demandas com o mesmo `data-testid` | 1 | **2** | esqueci que o teste da frase também busca por testid |
|
||||
| A frase "Sem próximo passo definido" some (fica só a cor) | 1 | **1** ✅ | acessibilidade é vigiada |
|
||||
| A seção vai para depois dos negócios | 1 | **1** ✅ | ordem é afirmação de qual é a unidade |
|
||||
| O flag `erro` não é setado | 1 | **1** ✅ | falha volta a parecer ausência |
|
||||
| `setDemandas(null)` → `setDemandas([])` no catch | 1 | **0** ⚠️ | ver abaixo |
|
||||
|
||||
**A última reprovou ZERO, e isso corrigiu meu modelo mental.** Eu creditava a
|
||||
proteção ao `setDemandas(null)`; o que realmente impede a mentira é o
|
||||
`setErro(true)`, que faz o `SemLista` mostrar "Não consegui ler"
|
||||
independentemente da lista estar `[]` ou `null`. O `null` é consistência com as
|
||||
irmãs, não a defesa. Mecanismo redundante identificado e crédito reatribuído — a
|
||||
sabotagem que vale para aquele caso é a do flag, e ela reprova 1.
|
||||
|
||||
### 🐛 Achado PRÉ-EXISTENTE, medido com controle: o painel do inbox não cabe na tela
|
||||
|
||||
| viewport | painel fica fora da viewport |
|
||||
|---|---|
|
||||
| 1280px | **311px** |
|
||||
| 1440px | **151px** |
|
||||
| 1920px | cabe (−24px) |
|
||||
|
||||
`InboxLayout.tsx:164` usa `xl:grid-cols-[300px_1fr_320px]`, e o `xl` do Tailwind
|
||||
dispara em 1280 — a terceira coluna nasce no exato ponto em que não há espaço
|
||||
para ela. Pior: `scroll_horizontal` é **false**, então **não há como alcançar** o
|
||||
que ficou fora.
|
||||
|
||||
**Controle que prova não ser meu:** a seção `CONTATO`, que existe desde antes
|
||||
desta branch, está exatamente tão fora quanto a minha (295px, mesma
|
||||
`left`/`right`). É o layout, não o conteúdo.
|
||||
|
||||
Em 1280px — resolução de trabalho comum — o atendente não vê o painel de CRM
|
||||
nenhum. Vale issue própria, e é da mesma família do overflow de 390px já
|
||||
registrado.
|
||||
|
||||
### Ambiente consertado de passagem
|
||||
|
||||
`.e2e-creds.json` tinha o segredo TOTP do admin **divergente do banco**
|
||||
(`YVVB64YO…` no arquivo, `RYA6TA36…` em `auth.mfa_factors`) — outra rodada do
|
||||
seed rotacionou e o arquivo ficou para trás. Corrigido a partir do banco; o
|
||||
login de admin nas sondas voltou a funcionar.
|
||||
|
||||
### ⚠️ NÃO MEDIDO — e a causa
|
||||
|
||||
`tests/sonda-painel-inbox.ts` (a guarda antiga do painel, que estendi para
|
||||
incluir "Nenhuma demanda aberta." na lista de frases que não podem aparecer sob
|
||||
falha) **não roda neste banco**: sua fixture `Ana Souza LGPD E2E` não existe
|
||||
aqui — medido, **0 contatos**. A extensão está escrita e não foi executada.
|
||||
|
||||
Isso não deixa buraco de cobertura: o mesmo comportamento está provado (a) no
|
||||
teste unitário, caso 3, com sabotagem do flag `erro` confirmada 1→1, e (b) na
|
||||
tela, na fase "SOB FALHA" da sonda nova. Mas a linha que escrevi naquele arquivo
|
||||
segue sem execução, e isso fica declarado.
|
||||
|
||||
### Pendente
|
||||
|
||||
- **A `description` morta do catálogo** — decisão de desenho (48 de 51 divergem).
|
||||
- **Issue do painel do inbox cortado** em 1280/1440px.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* PROVA DE TELA — a demanda no painel do inbox (passo 4 do cap. 5).
|
||||
*
|
||||
* ## O que esta sonda prova que o teste de componente NÃO pode
|
||||
*
|
||||
* O teste unitário mocka `apiClient` e prova o COMPORTAMENTO do painel. Ele não
|
||||
* toca a RLS. E aqui a RLS é o risco real: a rota `crm-summary` lê com o
|
||||
* **client de sessão**, e a política de `demandas` chama `fn_user_org_ids()`.
|
||||
* Se essa leitura não passar, o painel diz "Nenhuma demanda aberta." para
|
||||
* sempre — que é EXATAMENTE o defeito que a rota inteira veio curar (erro de
|
||||
* permissão traduzido em afirmação sobre o negócio), reintroduzido por mim numa
|
||||
* seção nova.
|
||||
*
|
||||
* Por isso o caso central não é "a seção apareceu": é **a seção mostrar as duas
|
||||
* demandas que existem no banco**, com a sem próximo passo distinguível.
|
||||
*
|
||||
* Uso: `npx tsx tests/sonda-inbox-demandas-tela.ts`
|
||||
* Requer o app na 3100 apontando para o Supabase LOCAL. Semeia e limpa.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const BASE = "http://127.0.0.1:3100";
|
||||
const CONVERSA = "8b9fcd5f-252d-4f6b-9538-f7c2c538807f";
|
||||
const CONTATO = "564d1c58-52a1-4610-bd43-2d88df13064c";
|
||||
const ORG = "ad365e5b-45e5-45d3-99fa-33b388501fec";
|
||||
const MARCA = "sonda-inbox-demanda";
|
||||
const c = JSON.parse(readFileSync("/Users/rafaelmelgaco/DeskcommCRM/.e2e-creds.json", "utf8"));
|
||||
|
||||
function psql(script: string): string {
|
||||
return execFileSync(
|
||||
"docker",
|
||||
["exec", "-i", "supabase_db_deskcomm-crm", "psql", "-U", "postgres", "-d", "postgres", "-tA"],
|
||||
{ input: script, encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
const limpar = `delete from public.demandas where assunto = '${MARCA}';`;
|
||||
|
||||
function semear(): number {
|
||||
psql(`
|
||||
${limpar}
|
||||
insert into public.demandas
|
||||
(organization_id, contact_id, aberta_em, origem, estado, assunto, proximo_passo, proximo_passo_em)
|
||||
values
|
||||
('${ORG}', '${CONTATO}', now() - interval '9 hours', 'inbound', 'aberta', '${MARCA}', null, null),
|
||||
('${ORG}', '${CONTATO}', now() - interval '3 hours', 'handoff', 'em_atendimento', '${MARCA}',
|
||||
'Enviar o orçamento revisado', now() + interval '1 day');
|
||||
`);
|
||||
// Quantas demandas ABERTAS o banco tem para este contato — a régua contra a
|
||||
// qual a tela é comparada. Sem ela, "apareceu alguma coisa" passaria por
|
||||
// prova.
|
||||
return Number(
|
||||
psql(
|
||||
`select count(*) from public.demandas where contact_id='${CONTATO}' and fechada_em is null;`,
|
||||
).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const noBanco = semear();
|
||||
const b = await chromium.launch();
|
||||
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
const erros: string[] = [];
|
||||
let statusSummary = 0;
|
||||
p.on("console", (m) => {
|
||||
if (m.type() === "error") erros.push(m.text());
|
||||
});
|
||||
p.on("response", (r) => {
|
||||
if (r.url().includes("/crm-summary")) statusSummary = r.status();
|
||||
});
|
||||
|
||||
await p.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await p.click('input[type="email"]');
|
||||
await p.locator('input[type="email"]').pressSequentially(c.users.manager.email, { delay: 8 });
|
||||
await p.click('input[type="password"]');
|
||||
await p.locator('input[type="password"]').pressSequentially(c.password, { delay: 8 });
|
||||
await p.click('button[type="submit"]');
|
||||
await p.waitForURL(/\/app/, { timeout: 30000 });
|
||||
|
||||
await p.goto(`${BASE}/app/inbox?id=${CONVERSA}`, { waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(3000);
|
||||
|
||||
const m = (await p.evaluate(`(() => {
|
||||
var sec = document.querySelector('[data-testid="inbox-demandas"]');
|
||||
if (!sec) return { presente: false };
|
||||
var r = sec.getBoundingClientRect();
|
||||
var sem = sec.querySelectorAll('[data-testid="demanda-sem-proximo-passo"]');
|
||||
var com = sec.querySelectorAll('[data-testid="demanda-com-proximo-passo"]');
|
||||
var leads = null;
|
||||
var h3s = document.querySelectorAll("h3");
|
||||
for (var i = 0; i < h3s.length; i++) {
|
||||
// case-INSENSITIVE: o h3 usa \`uppercase\` no CSS e \`innerText\` no browser
|
||||
// devolve "LEADS RECENTES". No jsdom do teste unitário não há
|
||||
// text-transform, então lá o texto casa e aqui não casava — a medida
|
||||
// voltava \`null\` e a asserção reprovava por não ter medido, não por
|
||||
// estar errada.
|
||||
if (/leads recentes/i.test(h3s[i].innerText)) { leads = h3s[i]; break; }
|
||||
}
|
||||
return {
|
||||
presente: true,
|
||||
texto: sec.innerText.replace(/\\s+/g, " ").trim(),
|
||||
itens_sem_passo: sem.length,
|
||||
itens_com_passo: com.length,
|
||||
// A frase, não só a cor — acessibilidade medida, não presumida.
|
||||
diz_por_escrito: /Sem próximo passo definido/.test(sec.innerText),
|
||||
// Zero lisonjeiro: com dados no banco, dizer "nenhuma" seria a mentira.
|
||||
afirma_nenhuma: /Nenhuma demanda aberta/.test(sec.innerText),
|
||||
confessa_falha: /Não consegui ler/.test(sec.innerText),
|
||||
antes_dos_leads: leads
|
||||
? !!(sec.compareDocumentPosition(leads) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
: null,
|
||||
largura: Math.round(r.width),
|
||||
visivel: r.width > 0 && r.height > 0,
|
||||
scroll_horizontal:
|
||||
document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
})()`)) as Record<string, unknown>;
|
||||
|
||||
// CONGELA as medidas do caminho feliz ANTES da fase de falha: o listener de
|
||||
// `response` e o array de `erros` são cumulativos, e o 500 injetado adiante
|
||||
// sobrescreveria o status e somaria um erro de console. A primeira versão
|
||||
// desta sonda reprovou "a rota respondeu 200" e "sem erro de console" por
|
||||
// isso — a medida do caminho feliz tinha sido contaminada pela fase seguinte.
|
||||
const statusFeliz = statusSummary;
|
||||
const errosFeliz = [...erros];
|
||||
console.log(JSON.stringify({ ...m, statusSummary: statusFeliz, noBanco, erros: errosFeliz }, null, 2));
|
||||
await p.screenshot({ path: "evidence/passo4-inbox-demandas.png", fullPage: false });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// O CAMINHO DA FALHA, na tela. `tests/sonda-painel-inbox.ts` já guarda isto
|
||||
// para as três seções antigas, mas não roda neste banco: sua fixture
|
||||
// ("Ana Souza LGPD E2E") não existe aqui — medido, 0 contatos. Em vez de
|
||||
// deixar a seção nova sem prova de tela no caminho de erro, o caso vem junto.
|
||||
//
|
||||
// A rota devolve 500 e o painel tem de CONFESSAR. Dizer "Nenhuma demanda
|
||||
// aberta" aqui seria uma afirmação sobre o negócio feita em cima de uma falha
|
||||
// de leitura — exatamente o defeito que a rota `crm-summary` veio curar.
|
||||
// ---------------------------------------------------------------------------
|
||||
await p.route("**/crm-summary", (r) =>
|
||||
r.fulfill({ status: 500, contentType: "application/json", body: '{"error":{"code":"boom"}}' }),
|
||||
);
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(2500);
|
||||
const falha = (await p.evaluate(`(() => {
|
||||
var sec = document.querySelector('[data-testid="inbox-demandas"]');
|
||||
if (!sec) return { presente: false };
|
||||
var t = sec.innerText;
|
||||
return {
|
||||
presente: true,
|
||||
confessa: /Não consegui ler/.test(t),
|
||||
mente: /Nenhuma demanda aberta/.test(t),
|
||||
oferece_retry: /Tentar de novo/.test(t),
|
||||
};
|
||||
})()`)) as Record<string, unknown>;
|
||||
console.log("SOB FALHA:", JSON.stringify(falha));
|
||||
await p.screenshot({ path: "evidence/passo4-inbox-demandas-falha.png", fullPage: false });
|
||||
|
||||
await b.close();
|
||||
psql(limpar);
|
||||
|
||||
const casos: Array<[string, boolean]> = [
|
||||
["a rota respondeu 200", statusFeliz === 200],
|
||||
["a seção está na tela", m.presente === true],
|
||||
["mostra TODAS as demandas abertas do banco", Number(m.itens_sem_passo) + Number(m.itens_com_passo) === noBanco],
|
||||
["a sem próximo passo é distinguível", Number(m.itens_sem_passo) >= 1],
|
||||
["a com próximo passo também aparece", Number(m.itens_com_passo) >= 1],
|
||||
["diz a ausência por escrito, não só por cor", m.diz_por_escrito === true],
|
||||
["NÃO afirma 'nenhuma demanda' havendo demanda", m.afirma_nenhuma === false],
|
||||
["não confessa falha de leitura (a RLS deixou ler)", m.confessa_falha === false],
|
||||
["vem antes dos negócios", m.antes_dos_leads === true],
|
||||
["sem scroll horizontal", m.scroll_horizontal === false],
|
||||
["sem erro de console", errosFeliz.length === 0],
|
||||
["sob falha: a seção continua na tela", falha.presente === true],
|
||||
["sob falha: CONFESSA que não conseguiu ler", falha.confessa === true],
|
||||
["sob falha: NÃO afirma 'nenhuma demanda aberta'", falha.mente === false],
|
||||
["sob falha: oferece tentar de novo", falha.oferece_retry === true],
|
||||
];
|
||||
let falhas = 0;
|
||||
for (const [nome, ok] of casos) {
|
||||
console.log(`${ok ? " ok " : "FALHA "} ${nome}`);
|
||||
if (!ok) falhas += 1;
|
||||
}
|
||||
console.log(falhas === 0 ? "\nTODOS OS CASOS PASSARAM" : `\n${falhas} CASO(S) FALHARAM`);
|
||||
process.exit(falhas === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
psql(limpar);
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -95,7 +95,13 @@ async function main(): Promise<void> {
|
||||
await abrirConversa(page);
|
||||
const textoFalha = await painel.innerText();
|
||||
|
||||
const mente = /Sem leads\.|Sem atividade\.|Sem pedidos\./.test(textoFalha);
|
||||
// "Nenhuma demanda aberta." entra na lista pelo MESMO motivo das outras
|
||||
// três: com a leitura falhando, é uma afirmação sobre o negócio feita em
|
||||
// cima de um erro. A seção nasceu depois da sonda; se não entrasse aqui, o
|
||||
// painel poderia voltar a mentir por um caminho que ninguém vigia.
|
||||
const mente = /Sem leads\.|Sem atividade\.|Sem pedidos\.|Nenhuma demanda aberta\./.test(
|
||||
textoFalha,
|
||||
);
|
||||
const confessa = /Não consegui ler/i.test(textoFalha);
|
||||
record(
|
||||
"2.8.d-falha-confessa",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { CRMSidePanel } from "@/components/inbox/CRMSidePanel";
|
||||
|
||||
/**
|
||||
* O painel monta `ConversationTagsEditor`, que usa react-query. Sem o provider
|
||||
* o componente estoura antes de renderizar qualquer seção — e o teste falharia
|
||||
* por montagem, não pelo que quer medir.
|
||||
*/
|
||||
function renderPainel() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CRMSidePanel conversation={conversation} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A DEMANDA no painel do inbox — passo 4 do cap. 5 da doutrina.
|
||||
*
|
||||
* O painel já mostrava negócio, pedido e histórico: três listas sobre o que JÁ
|
||||
* aconteceu. Nenhuma responde à pergunta que a pessoa do outro lado está
|
||||
* fazendo — o que ela pediu e ainda não foi resolvido. O caso concreto que isto
|
||||
* evita: o atendente encerra a conversa, a demanda segue aberta e sem próximo
|
||||
* passo, e o vazamento só reaparece depois como número numa métrica que ele não
|
||||
* abre.
|
||||
*
|
||||
* O que estes casos guardam, e por que cada um:
|
||||
*
|
||||
* 1. **Sem próximo passo é DISTINGUÍVEL** — não basta listar. Se as duas
|
||||
* demandas se parecem, o invariante 4 vira decoração: o atendente lê a lista
|
||||
* e não sabe qual delas está vazando.
|
||||
* 2. **A frase existe, não só a cor** — quem enxerga mal cor precisa ler a
|
||||
* mesma informação. Um teste que só checasse a classe CSS aprovaria uma tela
|
||||
* inacessível.
|
||||
* 3. **Erro NÃO vira "nenhuma demanda aberta"** — é o defeito que esta rota
|
||||
* inteira veio curar, e a seção nova poderia reintroduzi-lo por um caminho
|
||||
* que a sonda antiga não cobria.
|
||||
* 4. **A demanda vem ANTES do negócio** — ordem é a afirmação de qual é a
|
||||
* unidade (cap. 5). Se o lead vier primeiro, a tela diz que a unidade é o
|
||||
* negócio, contradizendo a doutrina que o resto da branch implementa.
|
||||
*/
|
||||
|
||||
const CONTACT = "c0000000-0000-4000-8000-000000000001";
|
||||
|
||||
const conversation = {
|
||||
id: "cv-1",
|
||||
organization_id: "org-1",
|
||||
contact_id: CONTACT,
|
||||
tags: [],
|
||||
// `contacts`, PLURAL — é como o painel deriva o contactId (linha 134). Com
|
||||
// `contact` o efeito nunca dispara e as seções ficam no esqueleto para sempre.
|
||||
contacts: { id: CONTACT, display_name: "Fulana", name: null, phone_number: "5511999", tags: [] },
|
||||
} as unknown as React.ComponentProps<typeof CRMSidePanel>["conversation"];
|
||||
|
||||
const RESPOSTA = {
|
||||
leads: [
|
||||
{
|
||||
id: "l-1",
|
||||
title: "Negócio existente",
|
||||
status: "open",
|
||||
value_cents: 10_000,
|
||||
currency: "BRL",
|
||||
updated_at: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
orders: [],
|
||||
activities: [],
|
||||
demandas: [
|
||||
{
|
||||
id: "d-1",
|
||||
aberta_em: new Date(Date.now() - 5 * 3_600_000).toISOString(),
|
||||
origem: "inbound",
|
||||
estado: "aberta",
|
||||
proximo_passo: null,
|
||||
proximo_passo_em: null,
|
||||
prazo_em: null,
|
||||
},
|
||||
{
|
||||
id: "d-2",
|
||||
aberta_em: new Date(Date.now() - 2 * 3_600_000).toISOString(),
|
||||
origem: "handoff",
|
||||
estado: "em_atendimento",
|
||||
proximo_passo: "Ligar amanhã de manhã",
|
||||
proximo_passo_em: "2026-08-08T12:00:00Z",
|
||||
prazo_em: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const get = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
apiClient: {
|
||||
get: (...args: unknown[]) => get(...args),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/hooks/pipelines/useDefaultPipeline", () => ({
|
||||
useDefaultPipeline: () => ({ data: null, isError: false }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
// Os editores de tag buscam vocabulário por react-query. Sem isto o painel
|
||||
// estoura em `(vocabulary ?? []).filter` e o teste falharia por MONTAGEM — um
|
||||
// vermelho que não diz nada sobre demanda.
|
||||
vi.mock("@/hooks/inbox/useConversationTags", () => ({
|
||||
useUpdateConversationTags: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useConversationTagVocabulary: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("@/hooks/contacts/useUpdateContact", () => ({
|
||||
useUpdateContact: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
});
|
||||
|
||||
describe("painel do inbox — demandas abertas", () => {
|
||||
it("distingue a demanda SEM próximo passo da que tem", async () => {
|
||||
get.mockResolvedValue({ data: RESPOSTA });
|
||||
renderPainel();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("inbox-demandas")).toBeTruthy());
|
||||
|
||||
// Guarda de vacuidade: as duas precisam estar na tela, senão "distingue"
|
||||
// passaria por só uma existir.
|
||||
const sem = await screen.findAllByTestId("demanda-sem-proximo-passo");
|
||||
const com = screen.getAllByTestId("demanda-com-proximo-passo");
|
||||
expect(sem.length).toBe(1);
|
||||
expect(com.length).toBe(1);
|
||||
});
|
||||
|
||||
it("a ausência do próximo passo é dita por ESCRITO, não só por cor", async () => {
|
||||
get.mockResolvedValue({ data: RESPOSTA });
|
||||
renderPainel();
|
||||
|
||||
const sem = await screen.findByTestId("demanda-sem-proximo-passo");
|
||||
expect(sem.textContent).toMatch(/sem próximo passo definido/i);
|
||||
// E a que TEM mostra qual é — listar sem dizer o que foi combinado obrigaria
|
||||
// o atendente a abrir outra tela para descobrir.
|
||||
const com = await screen.findByTestId("demanda-com-proximo-passo");
|
||||
expect(com.textContent).toMatch(/ligar amanhã de manhã/i);
|
||||
});
|
||||
|
||||
it("leitura que FALHA não vira 'nenhuma demanda aberta'", async () => {
|
||||
get.mockRejectedValue(new Error("500"));
|
||||
renderPainel();
|
||||
|
||||
const secao = await screen.findByTestId("inbox-demandas");
|
||||
await waitFor(() => expect(secao.textContent).toMatch(/não consegui ler/i));
|
||||
// A afirmação sobre o negócio não pode ser feita em cima de um erro de
|
||||
// leitura — é o defeito que a rota `crm-summary` inteira veio curar.
|
||||
expect(secao.textContent).not.toMatch(/nenhuma demanda aberta/i);
|
||||
});
|
||||
|
||||
it("contato SEM demanda aberta diz isso — e não some da tela", async () => {
|
||||
get.mockResolvedValue({ data: { ...RESPOSTA, demandas: [] } });
|
||||
renderPainel();
|
||||
|
||||
const secao = await screen.findByTestId("inbox-demandas");
|
||||
await waitFor(() => expect(secao.textContent).toMatch(/nenhuma demanda aberta/i));
|
||||
// Some da tela seria pior que dizer: o atendente não distinguiria "não tem"
|
||||
// de "esta versão não mostra".
|
||||
expect(screen.queryByTestId("demanda-sem-proximo-passo")).toBeNull();
|
||||
});
|
||||
|
||||
it("a demanda vem ANTES do negócio — a unidade é ela (cap. 5)", async () => {
|
||||
get.mockResolvedValue({ data: RESPOSTA });
|
||||
renderPainel();
|
||||
|
||||
const secao = await screen.findByTestId("inbox-demandas");
|
||||
const leads = screen.getByText("Leads recentes");
|
||||
// `compareDocumentPosition` mede a ordem no documento, não a aparência —
|
||||
// medida por ferramenta, nunca a olho.
|
||||
const posicao = secao.compareDocumentPosition(leads);
|
||||
expect(posicao & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user