From 69d73fe31f36f3bfda993e2a99fc36121f5b2b23 Mon Sep 17 00:00:00 2001 From: Bruno Freitas Date: Mon, 13 Jul 2026 23:08:57 -0300 Subject: [PATCH] =?UTF-8?q?fix(crm):=20scroll=20na=20ficha=20do=20contato,?= =?UTF-8?q?=20filtro=20no=20Kanban=20e=20per=C3=ADodo=20personalizado=20em?= =?UTF-8?q?=20Conversas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige o clipping silencioso da ficha de informações do contato: o item de grid (crm-thread-pane) não tinha min-height:0, então o CSS Grid deixava o conteúdo crescer além do container e o overflow:hidden do grid cortava tudo sem nunca mostrar scrollbar — reproduzido e validado com Chrome headless antes de corrigir. Ajusta o texto do campo personalizado vazio. Adiciona ao Kanban o mesmo painel de filtro (etiqueta + período) que já existia em Conversas, e em ambas as telas acrescenta um período "Personalizado…" com seleção de data(s) pelo usuário. Co-Authored-By: Claude Sonnet 5 --- app/dashboard/crm/page.tsx | 5 +- components/crm/ConversasFiltravel.tsx | 53 ++++++- components/crm/InformacoesContato.tsx | 2 +- components/crm/Kanban.tsx | 220 ++++++++++++++++++++++++-- lib/crm-periodos.ts | 11 ++ 5 files changed, 274 insertions(+), 17 deletions(-) diff --git a/app/dashboard/crm/page.tsx b/app/dashboard/crm/page.tsx index e1c92ff..9fc52b8 100644 --- a/app/dashboard/crm/page.tsx +++ b/app/dashboard/crm/page.tsx @@ -123,6 +123,7 @@ export default async function CrmPage({ style={{ borderRight: "0.5px solid rgba(255,255,255,0.08)", overflowY: "auto", + minHeight: 0, padding: 10, // Isola a pintura e impede o scroll de "vazar" pra página — // junto com content-visibility nas linhas, deixa o scroll @@ -143,7 +144,7 @@ export default async function CrmPage({ /> -
+
{lead ? ( - +
)} diff --git a/components/crm/ConversasFiltravel.tsx b/components/crm/ConversasFiltravel.tsx index 3039bb7..3750fa5 100644 --- a/components/crm/ConversasFiltravel.tsx +++ b/components/crm/ConversasFiltravel.tsx @@ -7,6 +7,7 @@ import type { CrmInstanciaRow } from "@/lib/crm-instancias-actions" import { PERIODOS_CONVERSA, calcularRangeConversa, + formatarDataBR, type PeriodoConversaKey, } from "@/lib/crm-periodos" import { casaBusca } from "@/lib/crm-busca" @@ -40,12 +41,33 @@ export default function ConversasFiltravel({ const [aberto, setAberto] = useState(false) const [etiquetaId, setEtiquetaId] = useState(null) const [periodo, setPeriodo] = useState("todos") + const [dataDe, setDataDe] = useState("") + const [dataAte, setDataAte] = useState("") const [busca, setBusca] = useState("") const hoje = useMemo(() => new Date(), []) + + // "Personalizado…": o usuário escolhe as datas — só "de" já filtra aquele + // dia único; "até" preenchido também estende pro intervalo. Enquanto + // nenhuma data foi escolhida ainda, cai no fallback (sem filtro) do + // calcularRangeConversa, igual ao período "Personalizado" do Calendário. + const rangePersonalizado = useMemo(() => { + if (periodo !== "personalizado" || !dataDe) return null + const iniD = new Date(`${dataDe}T00:00:00`) + const fimD = dataAte + ? new Date(`${dataAte}T23:59:59.999`) + : new Date(`${dataDe}T23:59:59.999`) + return iniD.getTime() <= fimD.getTime() + ? { inicio: iniD, fim: fimD } + : { inicio: new Date(`${dataAte}T00:00:00`), fim: new Date(`${dataDe}T23:59:59.999`) } + }, [periodo, dataDe, dataAte]) + const range = useMemo( - () => calcularRangeConversa(periodo, hoje), - [periodo, hoje] + () => + periodo === "personalizado" + ? rangePersonalizado + : calcularRangeConversa(periodo, hoje), + [periodo, hoje, rangePersonalizado] ) const leadsFiltrados = useMemo(() => { @@ -69,11 +91,16 @@ export default function ConversasFiltravel({ const etiquetaAtiva = etiquetaId ? etiquetas.find((e) => e.id === etiquetaId) : null - const periodoLabel = PERIODOS_CONVERSA.find((p) => p.chave === periodo)?.label + const periodoLabel = + periodo === "personalizado" && rangePersonalizado + ? `${formatarDataBR(rangePersonalizado.inicio)} – ${formatarDataBR(rangePersonalizado.fim)}` + : PERIODOS_CONVERSA.find((p) => p.chave === periodo)?.label function limpar() { setEtiquetaId(null) setPeriodo("todos") + setDataDe("") + setDataAte("") setBusca("") } @@ -189,6 +216,26 @@ export default function ConversasFiltravel({ ))} + {periodo === "personalizado" && ( +
+ setDataDe(e.target.value)} + className="glass-input" + style={{ fontSize: 12, padding: "6px 8px", borderRadius: 8, flex: 1, minWidth: 0 }} + /> + até + setDataAte(e.target.value)} + placeholder="Opcional" + className="glass-input" + style={{ fontSize: 12, padding: "6px 8px", borderRadius: 8, flex: 1, minWidth: 0 }} + /> +
+ )}
{temFiltro && ( diff --git a/components/crm/InformacoesContato.tsx b/components/crm/InformacoesContato.tsx index 29eaeff..ff5e825 100644 --- a/components/crm/InformacoesContato.tsx +++ b/components/crm/InformacoesContato.tsx @@ -223,7 +223,7 @@ export default function InformacoesContato({ {campos.length === 0 ? (

- Nenhum campo extra ainda — ex: CPF, aniversário, indicação… + Nenhum campo extra ainda. Ex: CPF, aniversário, indicação…

) : (
diff --git a/components/crm/Kanban.tsx b/components/crm/Kanban.tsx index ef4c198..20c8d0e 100644 --- a/components/crm/Kanban.tsx +++ b/components/crm/Kanban.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState, useTransition } from "react" +import { useEffect, useMemo, useState, useTransition } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { @@ -22,7 +22,7 @@ import { } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import type { CrmEtapaRow } from "@/lib/crm-etapas" -import type { CrmLeadRow } from "@/lib/crm-leads" +import type { CrmLeadRow, CrmEtiquetaResumo } from "@/lib/crm-leads" import { moverLeadAction, criarEtapaAction, @@ -32,6 +32,12 @@ import { } from "@/lib/crm-kanban-actions" import { CORES_ETIQUETA } from "@/lib/crm-cores" import { casaBusca } from "@/lib/crm-busca" +import { + PERIODOS_CONVERSA, + calcularRangeConversa, + formatarDataBR, + type PeriodoConversaKey, +} from "@/lib/crm-periodos" import Avatar from "@/components/crm/Avatar" import { EtiquetaChip } from "@/components/crm/Etiquetas" import InformacoesContato from "@/components/crm/InformacoesContato" @@ -83,14 +89,33 @@ function encontrarColuna( return null } +/** Filtro combinado (busca + etiqueta + período) — mesma lógica de + * ConversasFiltravel, aplicada por coluna antes de renderizar os cards. */ +function leadPassaFiltro( + lead: CrmLeadRow, + busca: string, + etiquetaId: string | null, + range: { inicio: Date; fim: Date } | null +): boolean { + if (etiquetaId && !lead.etiquetas.some((e) => e.id === etiquetaId)) return false + if (range) { + if (!lead.ultima_interacao_em) return false + const t = new Date(lead.ultima_interacao_em).getTime() + if (t < range.inicio.getTime() || t > range.fim.getTime()) return false + } + return casaBusca(busca, lead.nome, lead.telefone_e164, lead.empresa_nome) +} + export default function Kanban({ etapas, leads, corPorEmpresa, + etiquetas, }: { etapas: CrmEtapaRow[] leads: CrmLeadRow[] corPorEmpresa: Record + etiquetas: CrmEtiquetaResumo[] }) { const router = useRouter() const [colunas, setColunas] = useState>(() => @@ -98,7 +123,42 @@ export default function Kanban({ ) const [ativoId, setAtivoId] = useState(null) const [busca, setBusca] = useState("") + const [filtroAberto, setFiltroAberto] = useState(false) + const [etiquetaId, setEtiquetaId] = useState(null) + const [periodo, setPeriodo] = useState("todos") + const [dataDe, setDataDe] = useState("") + const [dataAte, setDataAte] = useState("") const [leadInfo, setLeadInfo] = useState(null) + + const hoje = useMemo(() => new Date(), []) + const rangePersonalizado = useMemo(() => { + if (periodo !== "personalizado" || !dataDe) return null + const iniD = new Date(`${dataDe}T00:00:00`) + const fimD = dataAte + ? new Date(`${dataAte}T23:59:59.999`) + : new Date(`${dataDe}T23:59:59.999`) + return iniD.getTime() <= fimD.getTime() + ? { inicio: iniD, fim: fimD } + : { inicio: new Date(`${dataAte}T00:00:00`), fim: new Date(`${dataDe}T23:59:59.999`) } + }, [periodo, dataDe, dataAte]) + const range = useMemo( + () => + periodo === "personalizado" ? rangePersonalizado : calcularRangeConversa(periodo, hoje), + [periodo, hoje, rangePersonalizado] + ) + const temFiltro = etiquetaId !== null || periodo !== "todos" || busca.trim() !== "" + const periodoLabel = + periodo === "personalizado" && rangePersonalizado + ? `${formatarDataBR(rangePersonalizado.inicio)} – ${formatarDataBR(rangePersonalizado.fim)}` + : PERIODOS_CONVERSA.find((p) => p.chave === periodo)?.label + + function limparFiltro() { + setEtiquetaId(null) + setPeriodo("todos") + setDataDe("") + setDataAte("") + setBusca("") + } const [, startTransition] = useTransition() // Ressincroniza quando o servidor manda leads/etapas atualizados @@ -176,14 +236,143 @@ export default function Kanban({ return (
- setBusca(e.target.value)} - placeholder="Buscar por nome, telefone ou empresa…" - className="glass-input" - style={{ fontSize: 12, padding: "7px 10px", borderRadius: 8, width: "100%", maxWidth: 320 }} - /> +
+ setBusca(e.target.value)} + placeholder="Buscar por nome, telefone ou empresa…" + className="glass-input" + style={{ fontSize: 12, padding: "7px 10px", borderRadius: 8, width: "100%", maxWidth: 320 }} + /> + +
+ + {filtroAberto && ( +
+
+

Etiqueta

+ {etiquetas.length === 0 ? ( +

+ Nenhuma etiqueta disponível. +

+ ) : ( +
+ {etiquetas.map((e) => { + const ativa = e.id === etiquetaId + return ( + + ) + })} +
+ )} +
+ +
+

Período (última interação)

+ + {periodo === "personalizado" && ( +
+ setDataDe(e.target.value)} + className="glass-input" + style={{ fontSize: 12, padding: "6px 8px", borderRadius: 8, flex: 1, minWidth: 0 }} + /> + até + setDataAte(e.target.value)} + placeholder="Opcional" + className="glass-input" + style={{ fontSize: 12, padding: "6px 8px", borderRadius: 8, flex: 1, minWidth: 0 }} + /> +
+ )} +
+ + {temFiltro && ( + + )} +
+ )} + + {temFiltro && !filtroAberto && ( +
+ {etiquetaId && ( + · {etiquetas.find((e) => e.id === etiquetaId)?.nome} + )} + {periodo !== "todos" && · {periodoLabel}} +
+ )}
- casaBusca(busca, l.nome, l.telefone_e164, l.empresa_nome) + leadPassaFiltro(l, busca, etiquetaId, range) )} corPorEmpresa={corPorEmpresa} isFirst={index === 0} @@ -851,3 +1040,12 @@ function Cartao({ ) } + +const rotuloEstiloKanban: React.CSSProperties = { + fontSize: 9, + letterSpacing: "1.5px", + textTransform: "uppercase", + color: "var(--text-4)", + fontWeight: 600, + marginBottom: 6, +} diff --git a/lib/crm-periodos.ts b/lib/crm-periodos.ts index f9497af..d2d88ba 100644 --- a/lib/crm-periodos.ts +++ b/lib/crm-periodos.ts @@ -15,6 +15,7 @@ export type PeriodoConversaKey = | "semana_passada" | "ult_90" | "ult_365" + | "personalizado" export const PERIODOS_CONVERSA: { chave: PeriodoConversaKey; label: string }[] = [ { chave: "todos", label: "Qualquer data" }, @@ -28,8 +29,13 @@ export const PERIODOS_CONVERSA: { chave: PeriodoConversaKey; label: string }[] = { chave: "semana_passada", label: "Semana passada" }, { chave: "ult_90", label: "Últimos 3 meses" }, { chave: "ult_365", label: "Últimos 365 dias" }, + { chave: "personalizado", label: "Personalizado…" }, ] +export function formatarDataBR(d: Date): string { + return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" }) +} + function inicioDoDia(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()) } @@ -79,5 +85,10 @@ export function calcularRangeConversa( return { inicio: addDias(h, -89), fim: fimDoDia(h) } case "ult_365": return { inicio: addDias(h, -364), fim: fimDoDia(h) } + case "personalizado": + // O intervalo real vem de "de"/"até" escolhidos pelo usuário — quem + // chama isso (ConversasFiltravel) calcula esse range à parte e só cai + // aqui como fallback antes de ambas as datas serem preenchidas. + return null } }