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
}
}