mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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
191 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|