Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/dashboard/crm/conexoes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Link from "next/link"
import { requererPermissao } from "@/lib/auth"
import { listarInstancias } from "@/lib/crm-instancias-actions"
import { listarEmpresas } from "@/lib/empresas-actions"
Expand All @@ -23,12 +24,19 @@ export default async function CrmConexoesPage() {
<main className="mx-auto px-8 py-10 space-y-8" style={{ maxWidth: 1120 }}>
<CrmRealtime />
<div>
<Link
href="/dashboard/crm"
style={{ fontSize: 12, color: "var(--gold, #C9953A)" }}
>
‹ Voltar às conversas
</Link>
<p
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--text-3)",
letterSpacing: "0.01em",
marginTop: 14,
}}
>
CRM
Expand Down
36 changes: 25 additions & 11 deletions components/crm/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export default function Kanban({
onDragEnd={handleDragEnd}
>
<div
className="flex gap-3 overflow-x-auto scrollbar-thin"
className="flex gap-4 overflow-x-auto scrollbar-thin"
style={{ height: "100%", paddingBottom: 8 }}
>
{etapas.map((etapa, index) => (
Expand Down Expand Up @@ -219,6 +219,7 @@ function Coluna({
isLast: boolean
}) {
const { setNodeRef, isOver } = useDroppable({ id: etapa.id })
const corEtapa = etapa.cor || "#8e7cc3"

return (
<div
Expand All @@ -228,30 +229,43 @@ function Coluna({
width: 280,
flexShrink: 0,
height: "100%",
background: isOver ? "rgba(201,149,58,0.06)" : "transparent",
borderRadius: 10,
padding: 8,
background: isOver ? `${corEtapa}1a` : "rgba(255,255,255,0.015)",
borderRadius: 12,
border: etapa.propria
? "0.5px dashed rgba(201,149,58,0.3)"
: "0.5px solid rgba(255,255,255,0.06)",
? "0.5px dashed rgba(201,149,58,0.35)"
: `1px solid ${corEtapa}33`,
overflow: "hidden",
transition: "background 0.15s ease",
}}
>
<div className="flex items-center justify-between px-1 mb-2" style={{ flexShrink: 0 }}>
{/* Faixa de topo com o fundo tingido na cor da etapa — separa
visualmente o cabeçalho (nome da fase) da área de cards, pra bater
o olho e entender o fluxo do funil sem precisar ler cada coluna. */}
<div
className="flex items-center justify-between"
style={{
flexShrink: 0,
padding: "10px 12px",
background: `${corEtapa}26`,
borderBottom: `1px solid ${corEtapa}4d`,
}}
>
<div className="flex items-center gap-1.5 min-w-0">
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: etapa.cor || "var(--text-4)",
background: corEtapa,
flexShrink: 0,
}}
/>
<p
style={{
fontSize: 12,
fontWeight: 600,
color: etapa.cor || "var(--text-2, #ddd)",
fontWeight: 700,
color: corEtapa,
letterSpacing: "0.01em",
}}
className="truncate"
>
Expand All @@ -268,7 +282,7 @@ function Coluna({
inteira pra ele) e rola por dentro quando tem muitos leads. */}
<div
className="space-y-2 scrollbar-thin"
style={{ flex: 1, minHeight: 40, overflowY: "auto", paddingRight: 2 }}
style={{ flex: 1, minHeight: 40, overflowY: "auto", padding: 8 }}
>
{leads.map((lead) => (
<CartaoArrastavel
Expand Down
64 changes: 7 additions & 57 deletions lib/crm-atividades-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"
import { getSupabaseAdmin } from "./supabase"
import { getUsuarioAtual } from "./auth"
import { tipoTecnicoDaCategoria } from "./crm-tipos-atividade"
import { sincronizarEtiquetaDaEtapa } from "./crm-etapas"
import { aplicarFaseAoLead } from "./crm-etapas"

export interface CrmAtividadeRow {
id: string
Expand Down Expand Up @@ -203,62 +203,12 @@ async function sincronizarLeadComEtapaDaCategoria(
)
if (!etapa) return

const etiquetaId = await sincronizarEtiquetaDaEtapa(db, usuarioId, etapa)

const { data: ultimoNaColuna } = await db
.from("crm_leads")
.select("ordem_na_etapa")
.eq("etapa_id", etapa.id)
.order("ordem_na_etapa", { ascending: false })
.limit(1)
.maybeSingle()
const novaOrdem = ((ultimoNaColuna?.ordem_na_etapa as number) ?? 0) + 1000

const patch: Record<string, unknown> = {
etapa_id: etapa.id,
ordem_na_etapa: novaOrdem,
updated_at: new Date().toISOString(),
}
if (etapa.tipo === "ganho") patch.status = "ganho"
else if (etapa.tipo === "perdido") patch.status = "perdido"
else patch.status = "aberto"

const { error: erroMover } = await db
.from("crm_leads")
.update(patch)
.eq("id", leadId)
.eq("usuario_id", usuarioId)
if (erroMover) {
console.error("[crm_leads] sync com categoria do agendamento falhou", erroMover.message)
return
}

if (!etiquetaId) return

// Etiqueta reflete a etapa ATUAL do lead (elas viram "fases" — troca, não
// acumula): tira qualquer etiqueta espelhada de OUTRA etapa antes de
// aplicar a nova, senão o card acumula tags de fases já ultrapassadas.
const { data: etiquetasDeEtapa } = await db
.from("crm_etiquetas")
.select("id")
.eq("usuario_id", usuarioId)
.not("etapa_id", "is", null)
.neq("id", etiquetaId)
const idsParaRemover = (etiquetasDeEtapa ?? []).map((e) => e.id as string)
if (idsParaRemover.length > 0) {
await db
.from("crm_lead_etiquetas")
.delete()
.eq("lead_id", leadId)
.in("etiqueta_id", idsParaRemover)
}

const { error: erroTag } = await db
.from("crm_lead_etiquetas")
.upsert({ lead_id: leadId, etiqueta_id: etiquetaId }, { onConflict: "lead_id,etiqueta_id" })
if (erroTag) {
console.error("[crm_lead_etiquetas] sync com categoria do agendamento falhou", erroTag.message)
}
await aplicarFaseAoLead(db, usuarioId, leadId, {
id: etapa.id as string,
nome: etapa.nome as string,
tipo: etapa.tipo as "aberta" | "ganho" | "perdido",
cor: (etapa.cor as string) ?? null,
})
}

/** Tipos de atividade CUSTOM do usuario (alem dos 4 padrao, que ficam em
Expand Down
146 changes: 146 additions & 0 deletions lib/crm-etapas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,149 @@ export async function sincronizarEtiquetaDaEtapa(
}
return (criada?.id as string) ?? null
}

/**
* Fase 8: espelho etapa -> etiqueta. Aplica no lead a etiqueta que espelha
* `etapa` e remove qualquer OUTRA etiqueta-de-etapa que ele tivesse — elas
* representam a fase ATUAL (mutuamente exclusivas), não um histórico
* acumulado. Etiquetas sem etapa_id (nenhuma deveria existir pós-Fase 6, mas
* por segurança) não são tocadas.
*/
export async function sincronizarEtiquetaComEtapa(
db: SupabaseClient,
usuarioId: string,
leadId: string,
etapa: { id: string; nome: string; cor: string | null }
): Promise<void> {
const etiquetaId = await sincronizarEtiquetaDaEtapa(db, usuarioId, etapa)
if (!etiquetaId) return

const { data: etiquetasDeEtapa } = await db
.from("crm_etiquetas")
.select("id")
.eq("usuario_id", usuarioId)
.not("etapa_id", "is", null)
.neq("id", etiquetaId)
const idsParaRemover = (etiquetasDeEtapa ?? []).map((e) => e.id as string)
if (idsParaRemover.length > 0) {
await db
.from("crm_lead_etiquetas")
.delete()
.eq("lead_id", leadId)
.in("etiqueta_id", idsParaRemover)
}

const { error } = await db
.from("crm_lead_etiquetas")
.upsert({ lead_id: leadId, etiqueta_id: etiquetaId }, { onConflict: "lead_id,etiqueta_id" })
if (error) {
console.error("[crm_lead_etiquetas] sincronizarEtiquetaComEtapa falhou", error.message)
}
}

/**
* Move o lead pra `etapa` (fim da coluna) E sincroniza a etiqueta espelhada
* — é o mesmo efeito de arrastar o card no Kanban. Usado sempre que a
* mudança de fase é disparada de FORA do drag-and-drop (escolher etiqueta em
* Conversas, agendar com categoria = nome de etapa, transições automáticas).
*/
export async function aplicarFaseAoLead(
db: SupabaseClient,
usuarioId: string,
leadId: string,
etapa: { id: string; nome: string; tipo: CrmEtapaRow["tipo"]; cor: string | null }
): Promise<void> {
const { data: ultimoNaColuna } = await db
.from("crm_leads")
.select("ordem_na_etapa")
.eq("etapa_id", etapa.id)
.order("ordem_na_etapa", { ascending: false })
.limit(1)
.maybeSingle()
const novaOrdem = ((ultimoNaColuna?.ordem_na_etapa as number) ?? 0) + 1000

const patch: Record<string, unknown> = {
etapa_id: etapa.id,
ordem_na_etapa: novaOrdem,
updated_at: new Date().toISOString(),
}
if (etapa.tipo === "ganho") patch.status = "ganho"
else if (etapa.tipo === "perdido") patch.status = "perdido"
else patch.status = "aberto"

const { error } = await db
.from("crm_leads")
.update(patch)
.eq("id", leadId)
.eq("usuario_id", usuarioId)
if (error) {
console.error("[crm_leads] aplicarFaseAoLead falhou", error.message)
return
}

await sincronizarEtiquetaComEtapa(db, usuarioId, leadId, etapa)
}
Comment on lines +138 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Silent failure contract can misrepresent success to end users.

aplicarFaseAoLead never throws and never returns a status — it only logs on error (Line 173-176) or when the internal sync fails. Every current caller treats the await as "done" and reports ok: true regardless. This is defensible for callers where the phase move is a secondary/best-effort side effect (e.g. criarFollowUpAction), but in atribuirEtiquetaAction (lib/crm-etiquetas-actions.ts, Lines 104-113) the phase move is the entire purpose of that early-return branch — if the underlying crm_leads update fails, the action still returns { ok: true }, misleading the user into thinking the tag/stage change succeeded.

Consider having aplicarFaseAoLead return a boolean (or throw) so at least the primary-purpose callers can propagate the real outcome.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crm-etapas.ts` around lines 138 - 179, Update aplicarFaseAoLead to expose
whether the crm_leads update succeeded, preferably by returning a boolean while
preserving its existing best-effort synchronization behavior. In
atribuirEtiquetaAction, inspect that result in the primary stage-assignment
branch and return a failure response when the phase move fails instead of
reporting ok: true; update secondary callers such as criarFollowUpAction to
preserve their current best-effort behavior.


function normalizarNomeEtapa(nome: string): string {
return nome.trim().toLowerCase()
}

/**
* Resolve as duas etapas usadas pelas ÚNICAS transições automáticas do
* funil (criação de lead e primeira mensagem) pelo NOME, entre as etapas
* visíveis ao usuário. Retorna null pra qualquer uma que não exista (ex: o
* usuário renomeou/excluiu a etapa padrão) — a automação correspondente
* simplesmente não dispara, sem quebrar o fluxo principal.
*/
export async function buscarEtapasAutomaticas(
db: SupabaseClient,
usuarioId: string
): Promise<{ novoContato: CrmEtapaRow | null; emConversa: CrmEtapaRow | null }> {
const { data } = await db
.from("crm_etapas")
.select("id, nome, ordem, tipo, cor, usuario_id")
.eq("ativo", true)
.or(`usuario_id.is.null,usuario_id.eq.${usuarioId}`)
const etapas = (data ?? []) as Record<string, any>[]
const acha = (nomeAlvo: string): CrmEtapaRow | null => {
const row = etapas.find((e) => normalizarNomeEtapa(e.nome as string) === nomeAlvo)
if (!row) return null
return {
id: row.id as string,
nome: row.nome as string,
ordem: row.ordem as number,
tipo: row.tipo as CrmEtapaRow["tipo"],
cor: (row.cor as string) ?? null,
propria: row.usuario_id === usuarioId,
}
}
return { novoContato: acha("novo contato"), emConversa: acha("em conversa") }
}

/**
* As duas ÚNICAS transições automáticas do funil (o resto avança manualmente
* pela percepção do usuário com o lead): criação de lead -> "Novo Contato" é
* feita no próprio insert (webhook/manual); esta função cobre a segunda —
* primeira mensagem no chat -> "Em conversa". Chamada toda vez que uma
* mensagem nova é gravada (inbound OU outbound); só age se o lead ainda
* estiver exatamente em "Novo Contato" — se ele já foi movido (manualmente
* ou por essa mesma automação numa mensagem anterior), não mexe mais.
*/
export async function avancarParaEmConversaSePrimeiraMsg(
db: SupabaseClient,
usuarioId: string,
leadId: string
): Promise<void> {
const { data: lead } = await db
.from("crm_leads")
.select("etapa_id")
.eq("id", leadId)
.maybeSingle()
if (!lead) return

const { novoContato, emConversa } = await buscarEtapasAutomaticas(db, usuarioId)
if (!novoContato || !emConversa) return
if (lead.etapa_id !== novoContato.id) return

await aplicarFaseAoLead(db, usuarioId, leadId, emConversa)
}
30 changes: 28 additions & 2 deletions lib/crm-etiquetas-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"
import { getSupabaseAdmin } from "./supabase"
import { getUsuarioAtual } from "./auth"
import type { CrmEtiquetaResumo } from "./crm-leads"
import { sincronizarEtiquetaDaEtapa } from "./crm-etapas"
import { sincronizarEtiquetaDaEtapa, aplicarFaseAoLead } from "./crm-etapas"

export interface ResultadoEtiqueta {
ok: boolean
Expand Down Expand Up @@ -83,10 +83,36 @@ export async function atribuirEtiquetaAction(
// um lead só pode ganhar etiquetas do MESMO dono).
const [{ data: lead }, { data: etiqueta }] = await Promise.all([
db.from("crm_leads").select("id").eq("id", leadId).eq("usuario_id", usuario.id).maybeSingle(),
db.from("crm_etiquetas").select("id").eq("id", etiquetaId).eq("usuario_id", usuario.id).maybeSingle(),
db
.from("crm_etiquetas")
.select("id, etapa_id")
.eq("id", etiquetaId)
.eq("usuario_id", usuario.id)
.maybeSingle(),
])
if (!lead || !etiqueta) return { ok: false, erro: "Lead ou etiqueta não encontrado." }

// Fase 8: etiqueta espelha uma etapa do Kanban — atribuir aqui move o card
// pra coluna correspondente também (mesma troca, não acumula: remove
// qualquer outra etiqueta-de-etapa que o lead tivesse).
if (etiqueta.etapa_id) {
const { data: etapa } = await db
.from("crm_etapas")
.select("id, nome, tipo, cor")
.eq("id", etiqueta.etapa_id as string)
.maybeSingle()
if (etapa) {
await aplicarFaseAoLead(db, usuario.id, leadId, {
id: etapa.id as string,
nome: etapa.nome as string,
tipo: etapa.tipo as "aberta" | "ganho" | "perdido",
cor: (etapa.cor as string) ?? null,
})
revalidarCrm()
return { ok: true }
}
}
Comment on lines +98 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Etapa lookup doesn't filter ativo = true, unlike the equivalent lookup elsewhere.

sincronizarLeadComEtapaDaCategoria (lib/crm-atividades-actions.ts, Line 199) filters etapas by ativo = true before applying them, but this lookup doesn't. If an etiqueta still references a since-deactivated etapa (etapa_id pointing at a stage no longer shown in the Kanban board), assigning that tag will move the lead into a stage that no column renders — effectively hiding the lead from the pipeline view until someone notices and manually reassigns it.

🛡️ Guard against applying an inactive etapa
     const { data: etapa } = await db
       .from("crm_etapas")
       .select("id, nome, tipo, cor")
       .eq("id", etiqueta.etapa_id as string)
+      .eq("ativo", true)
       .maybeSingle()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (etiqueta.etapa_id) {
const { data: etapa } = await db
.from("crm_etapas")
.select("id, nome, tipo, cor")
.eq("id", etiqueta.etapa_id as string)
.maybeSingle()
if (etapa) {
await aplicarFaseAoLead(db, usuario.id, leadId, {
id: etapa.id as string,
nome: etapa.nome as string,
tipo: etapa.tipo as "aberta" | "ganho" | "perdido",
cor: (etapa.cor as string) ?? null,
})
revalidarCrm()
return { ok: true }
}
}
if (etiqueta.etapa_id) {
const { data: etapa } = await db
.from("crm_etapas")
.select("id, nome, tipo, cor")
.eq("id", etiqueta.etapa_id as string)
.eq("ativo", true)
.maybeSingle()
if (etapa) {
await aplicarFaseAoLead(db, usuario.id, leadId, {
id: etapa.id as string,
nome: etapa.nome as string,
tipo: etapa.tipo as "aberta" | "ganho" | "perdido",
cor: (etapa.cor as string) ?? null,
})
revalidarCrm()
return { ok: true }
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crm-etiquetas-actions.ts` around lines 98 - 114, Update the etapa lookup
in the etiqueta assignment flow around aplicarFaseAoLead to filter crm_etapas by
ativo = true before applying the stage. Preserve the existing behavior for
active stages and the fallback when no matching etapa is found.


const { error } = await db
.from("crm_lead_etiquetas")
.upsert({ lead_id: leadId, etiqueta_id: etiquetaId }, { onConflict: "lead_id,etiqueta_id" })
Expand Down
Loading