Files
DeskcommCRM/components/inbox/ConversationHeader.tsx
T
Rafael MelgaçoandClaude Opus 5 c56416aad9 fix(inbox): o painel de CRM cabe na tela — e o header para de travar a largura
Em 1280px o painel de CRM não existia na tela. Não cortado, não parcial:
ausente. O atendente trabalhava sem contexto nenhum do cliente na resolução de
notebook mais comum que existe.

## A causa não era onde parecia

O suspeito óbvio era o grid (`xl:grid-cols-[300px_1fr_320px]`, e o `xl` dispara
justo em 1280). A medição apontou outro lugar:

    grid-template-columns resolvido, de 1280 a 1536:  "300px 706.953px 320px"
                                            em 1920:  "300px 1012px    320px"

A coluna do meio TRAVAVA em 706,95px — o `min-content` dela; `1fr` é
`minmax(auto, 1fr)` e não encolhe abaixo do conteúdo. Filho a filho: o thread
pedia 132px, o composer 370, e o ConversationHeader pedia 707, porque a barra de
ações era `shrink-0`. Um `shrink-0` numa fileira de botões estava definindo a
largura mínima da aplicação inteira.

## Correção de uma afirmação minha

No commit anterior escrevi que o painel era "inalcançável". Errado: o `main` tem
`overflow-x: auto` e `scrollWidth 1351 > clientWidth 1040` — havia scroll, no
`main`, não no documento; eu medira o do documento. Continua defeito (rolar o
inbox de lado para ver o CRM é ruim e ninguém descobre), mas menos grave.

## Escolhido por medição, não por palpite

Cada build custa ~2min, então as variantes foram injetadas por CSS no browser já
renderizado e medidas na hora, em 5 larguras. Três decisões saíram daí:

  * `minmax(0,1fr)` NÃO entrou — mediu idêntico a não tê-lo. Consertado o
    header, o `1fr` encolhe sozinho. Seria mudança sem efeito.
  * Só consertar o header deixava 372px de thread contra um piso de 370 do
    composer: 2px não é margem, é sorte. Duas faixas (compacta no `xl`, generosa
    no `2xl`) dão 424px — 54px de folga — e preservam as laterais onde há espaço.
  * Apertar o gap dos botões economizava 4-6px de altura; não paga a densidade.

## A lapidação que não era sobre caber

O placeholder passou a quebrar em duas linhas num campo de uma linha. Aqui a
screenshot me enganou e a medição corrigiu: eu li como "composer cortado", e
`composer_cortado` media 0 em todas as variantes — era texto, não layout.

O atalho saiu do placeholder para o diálogo de atalhos e o `title`. Ele some
assim que se digita a primeira letra — some exatamente quando você ia quebrar
linha. E o diálogo de atalhos não tinha o atalho mais usado do inbox.
"(só o time vê)" ficou: não é atalho, é consequência.

"Ver contato" aparecia DUAS VEZES na mesma tela — header e card CONTATO do
painel — e era ele que sobrava na segunda linha. Com `xl:hidden` (a mesma
largura em que o painel entra), os botões voltam a caber em uma linha em 1280 e
o header cai de 145px para 107. Abaixo de xl o painel não existe e o botão
continua lá. Não é esconder ação; é parar de repeti-la.

## Medido (tests/sonda-inbox-cabe-na-tela.ts, 10/10)

        vp   fora  painel  thread  hdr_h   acoes  portas
      1280      0     296     424    107     0/4       1     (antes: 311 fora, 707 travado)
      1366      0     296     510    107     0/4       1     (antes: 225 fora)
      1440      0     296     584    107     0/4       1     (antes: 151 fora)
      1536      0     320     628     63     0/4       1     (antes:  55 fora)
      1920      0     320    1012     63     0/4       1     (controle: já cabia)

Duas camadas de guarda, com a limitação declarada: a sonda é a medição de
verdade e NÃO roda no CI; `tests/unit/inbox-header-nao-trava.test.tsx` é a
catraca que roda, olha CLASSE e não pixel — jsdom não tem engine de layout, e um
teste de largura lá mediria zero em tudo e passaria feliz.

Sabotagens: `shrink-0` de volta → previsão 1, resultado 2 (troquei o className
inteiro, levando junto o `flex-wrap`); `flex-wrap` fora → 1 → 1.

## Defeitos meus, achados no caminho

  * mock de `useResumeAi` — módulo que NÃO existe (o real é
    `useResumeAiAttendance`). O teste passava porque o hook verdadeiro rodava: o
    mock não mockava nada e ninguém era avisado.
  * crases dentro de template literal na sonda — `tsc` reprovou; mesmo erro que
    já cometi com SQL.
  * duas imagens versionadas sem citação no commit 3227bf81 — o gate
    `evidencia-citada` reprovou. O pre-commit não roda a suíte, então aquele
    commit deixou o CI vermelho e eu não vi. Corrigido aqui.

typecheck 0 · lint 0 errors · 1819 unitários em 170 arquivos

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkS3mzwtXughmjVC5FCoNo
2026-08-07 11:32:29 -03:00

191 lines
8.1 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Phone, ArrowRight } from "@/lib/ui/icons";
import { useAuth } from "@/hooks/auth/AuthProvider";
import { useClaimConversation } from "@/hooks/inbox/useClaimConversation";
import { useReleaseConversation } from "@/hooks/inbox/useReleaseConversation";
import { useCloseConversation } from "@/hooks/inbox/useCloseConversation";
import { useResumeAiAttendance } from "@/hooks/inbox/useResumeAiAttendance";
import { ReassignDialog } from "@/components/inbox/ReassignDialog";
import { SnoozeButton } from "@/components/inbox/SnoozeButton";
import type { ConversationWithContact } from "@/hooks/inbox/useConversationsRealtime";
interface Props {
conversation: ConversationWithContact;
}
const STATUS_LABEL: Record<string, string> = {
open: "Aberta",
// É EXATAMENTE o estado em que a passagem para humano deixa a conversa
// (`performHumanHandoff`: 'ai_handling' → 'pending'), e o rótulo faltava — toda
// conversa escalada mostrava `pending` cru no rosto do atendente. O
// `conversationStatusSchema` não lista 'pending' porque valida ENTRADA da API;
// quem escreve este estado é o motor, e a tela precisa saber lê-lo.
pending: "Aguardando atendente",
claimed: "Em atendimento",
ai_handling: "IA atendendo",
closed: "Fechada",
archived: "Arquivada",
};
export function ConversationHeader({ conversation }: Props) {
const { user } = useAuth();
const claim = useClaimConversation();
const release = useReleaseConversation();
const close = useCloseConversation();
const retomar = useResumeAiAttendance();
const [reassignOpen, setReassignOpen] = useState(false);
const c = conversation.contacts ?? null;
const displayName = c?.display_name?.trim() || c?.name?.trim() || c?.phone_number || "Sem nome";
const phone = c?.phone_number ?? null;
const status = conversation.status;
const isMineAssigned = conversation.assigned_to_user_id === user.id;
const isOpen = status === "open" || conversation.assigned_to_user_id == null;
/**
* A conversa saiu do atendimento automático? As DUAS travas contam: o silêncio
* na conversa e o `force_human` no contato. Olhar só o silêncio deixaria de
* oferecer a volta justamente no caso em que ela mais falta — o contato travado
* com a conversa já liberada, em que nenhum envio automático sai e nada na tela
* explica por quê.
*/
const silenciada =
conversation.bot_silenced_until !== null && conversation.bot_silenced_until !== undefined;
const emAtendimentoHumano =
(silenciada || c?.force_human === true) && status !== "closed" && status !== "archived";
return (
// `flex-wrap` porque este header travava a LARGURA DA TELA INTEIRA. Ele
// media 707px de `min-content` — a identidade do contato encolhia bem
// (`min-w-0` + `truncate`), mas a barra de ações era `shrink-0` e não
// quebrava. Como a coluna do meio do inbox é `1fr`, que é
// `minmax(auto, 1fr)`, ela não podia ficar menor que esses 707px, e o
// painel de CRM era empurrado 311px para fora da viewport em 1280px.
//
// Reorganizar em vez de esconder: acima de ~1440px o header fica IDÊNTICO ao
// de antes (uma linha), e quando aperta a barra desce para a linha de baixo.
// Nenhuma ação some — um menu "mais" esconderia o "Lembrar" que a spec
// `canais-baseline` clica, e, pior, esconderia ação de quem atende.
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-background px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold">{displayName}</h2>
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
{STATUS_LABEL[status] ?? status}
</Badge>
{/* Sem esta marca, a conversa em que o robô está calado tem exatamente
a mesma cara de uma conversa normal — e ninguém entende por que as
respostas automáticas pararam. */}
{emAtendimentoHumano && (
<Badge variant="outline" className="h-4 px-1.5 text-[10px]" data-testid="badge-atendimento-humano">
Automático pausado
</Badge>
)}
</div>
{phone && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Phone size={11} weight="regular" aria-hidden /> {phone}
</p>
)}
</div>
{/* `shrink-0` saiu daqui: era ele que impunha o piso de largura. Agora a
barra pode encolher e quebrar internamente, e os botões continuam
todos visíveis e clicáveis — só que em duas linhas quando preciso. */}
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{isOpen && (
<Button
size="sm"
variant="default"
disabled={claim.isPending}
onClick={() =>
claim.mutate({
conversation_id: conversation.id,
expected_assignee: conversation.assigned_to_user_id,
})
}
>
Assumir
</Button>
)}
{isMineAssigned && (
<Button
size="sm"
variant="outline"
disabled={release.isPending}
onClick={() => release.mutate({ conversation_id: conversation.id })}
>
Liberar
</Button>
)}
{/* A volta. Fica ANTES de transferir/fechar porque é a ação que a pessoa
procura quando terminou o que tinha para fazer aqui. */}
{emAtendimentoHumano && (
<Button
size="sm"
variant="outline"
disabled={retomar.isPending}
data-testid="devolver-ao-automatico"
onClick={() => retomar.mutate({ conversation_id: conversation.id })}
>
{retomar.isPending ? "Devolvendo..." : "Devolver ao automático"}
</Button>
)}
{status !== "closed" && status !== "archived" && (
<Button size="sm" variant="outline" onClick={() => setReassignOpen(true)}>
Transferir
</Button>
)}
{status !== "closed" && status !== "archived" && (
<SnoozeButton
conversationId={conversation.id}
snoozeUntil={conversation.snooze_until ?? null}
/>
)}
{status !== "closed" && status !== "archived" && (
<Button
size="sm"
variant="outline"
disabled={close.isPending}
onClick={() => {
if (confirm("Fechar esta conversa?")) {
close.mutate({ conversation_id: conversation.id });
}
}}
>
Fechar
</Button>
)}
{/* `xl:hidden` porque a partir de 1280px o painel lateral de CRM entra
na tela — e ele já tem um "Ver contato", para o MESMO contato, a um
palmo de distância. Duas portas idênticas na mesma tela não são
redundância inofensiva: são a linha a mais que empurrava a barra de
ações para uma segunda fileira justo na largura mais apertada.
Medido: sem a duplicata, os botões voltam a caber em UMA linha em
1280px.
Abaixo de 1280 o painel não existe, e aí esta é a única porta para o
contato — por isso a condição é a mesma do painel, e não um valor
escolhido à parte. Não é esconder ação; é não repeti-la. */}
{c?.id && (
<Button asChild size="sm" variant="ghost" className="xl:hidden">
<Link href={`/app/contacts/${c.id}`} className="flex items-center gap-1">
Ver contato
<ArrowRight size={12} weight="regular" aria-hidden />
</Link>
</Button>
)}
</div>
<ReassignDialog
conversationId={conversation.id}
open={reassignOpen}
onOpenChange={setReassignOpen}
/>
</div>
);
}