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
4 changes: 2 additions & 2 deletions app/api/crm/wa/webhook/[segredo]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export const maxDuration = 60
* Configurar na Evolution (por instancia ou global) apontando pra:
* POST <APP_URL>/api/crm/wa/webhook/<EVOLUTION_WEBHOOK_SECRET>
* com os eventos MESSAGES_UPSERT, CONNECTION_UPDATE, QRCODE_UPDATED,
* CONTACTS_UPSERT, CONTACTS_UPDATE (+ futuramente MESSAGES_UPDATE, pra
* status de entrega/leitura).
* CONTACTS_UPSERT, CONTACTS_UPDATE, CHATS_DELETE (+ futuramente
* MESSAGES_UPDATE, pra status de entrega/leitura).
*/
export async function POST(
req: Request,
Expand Down
35 changes: 31 additions & 4 deletions app/dashboard/crm/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import Link from "next/link"
import { requererPermissao } from "@/lib/auth"
import { listarLeadsInbox, buscarLead, listarMensagensDoLead } from "@/lib/crm-leads"
import {
listarLeadsInbox,
buscarLead,
listarMensagensDoLead,
contarLeadsArquivados,
} from "@/lib/crm-leads"
import { listarInstancias } from "@/lib/crm-instancias-actions"
import { listarEtapas } from "@/lib/crm-etapas"
import { listarEtiquetas } from "@/lib/crm-etiquetas-actions"
Expand All @@ -14,6 +19,7 @@ import Thread from "@/components/crm/Thread"
import CrmRealtime from "@/components/crm/CrmRealtime"
import Kanban from "@/components/crm/Kanban"
import Calendario from "@/components/crm/Calendario"
import NovoContato from "@/components/crm/NovoContato"

export const dynamic = "force-dynamic"

Expand All @@ -35,7 +41,7 @@ const ABAS: { chave: Aba; label: string }[] = [
export default async function CrmPage({
searchParams,
}: {
searchParams: { lead?: string; view?: string }
searchParams: { lead?: string; view?: string; arquivados?: string }
}) {
await requererPermissao("crm")

Expand All @@ -44,21 +50,27 @@ export default async function CrmPage({
)
? (searchParams.view as Aba)
: "conversas"
const verArquivados = searchParams.arquivados === "1"

const agora = new Date()
const inicioJanela = new Date(agora.getFullYear(), agora.getMonth() - 2, 1).toISOString()
const fimJanela = new Date(agora.getFullYear(), agora.getMonth() + 7, 0).toISOString()

const [leads, instancias, etapas, etiquetas, atividades, proximas, tipos] =
const [leads, leadsArquivados, instancias, etapas, etiquetas, atividades, proximas, tipos, totalArquivados] =
await Promise.all([
listarLeadsInbox(),
verArquivados ? listarLeadsInbox({ arquivados: true }) : Promise.resolve([]),
listarInstancias(),
listarEtapas(),
listarEtiquetas(),
listarAtividadesCalendario(inicioJanela, fimJanela),
listarProximasAtividades(),
listarTiposAtividade(),
contarLeadsArquivados(),
])
// Kanban/Calendário sempre usam os ativos — "Ver arquivados" é só uma
// lente da lista de Conversas, não afeta as outras abas.
const leadsConversas = verArquivados ? leadsArquivados : leads

const corPorEmpresa: Record<string, string> = {}
for (const inst of instancias) corPorEmpresa[inst.empresa_slug] = inst.cor
Expand Down Expand Up @@ -118,8 +130,23 @@ export default async function CrmPage({
WebkitOverflowScrolling: "touch",
}}
>
<div className="flex items-center justify-between gap-2" style={{ padding: "2px 4px 10px" }}>
<NovoContato instancias={instancias} />
{(verArquivados || totalArquivados > 0) && (
<Link
href={
verArquivados
? "/dashboard/crm?view=conversas"
: "/dashboard/crm?view=conversas&arquivados=1"
}
style={{ fontSize: 11, color: "var(--text-3)" }}
>
{verArquivados ? "‹ Voltar" : `Arquivados (${totalArquivados})`}
</Link>
)}
</div>
<ListaConversas
leads={leads}
leads={leadsConversas}
leadSelecionadoId={lead?.id}
corPorEmpresa={corPorEmpresa}
/>
Expand Down
208 changes: 208 additions & 0 deletions components/crm/NovoContato.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"use client"

import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { criarLeadManualAction } from "@/lib/crm-leads-actions"
import type { CrmInstanciaRow } from "@/lib/crm-instancias-actions"

/** Espelha normalizarTelefone de lib/crm-leads-actions.ts — só pra pré-visualização
* no form (a normalização de verdade acontece no server). */
function previaTelefone(bruto: string): string {
const digitos = bruto.replace(/\D/g, "")
if (digitos.length < 8) return "número muito curto"
if (digitos.length <= 11) return `55${digitos}`
return digitos
}

/**
* Botão "+ Novo contato" da sidebar de Conversas — cria um lead manual
* (fora do fluxo de mensagem recebida). O contato passa a existir no CRM na
* hora; só vira uma conversa de verdade no WhatsApp do celular quando a
* PRIMEIRA mensagem é mandada (por isso já leva direto pra thread dele ao
* salvar — é só digitar e enviar).
*/
export default function NovoContato({
instancias,
}: {
instancias: CrmInstanciaRow[]
}) {
const ativas = instancias.filter((i) => i.ativo)
const [aberto, setAberto] = useState(false)
const [instanciaId, setInstanciaId] = useState(ativas[0]?.id ?? "")
const [nome, setNome] = useState("")
const [telefone, setTelefone] = useState("")
const [email, setEmail] = useState("")
const [erro, setErro] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const router = useRouter()

function fechar() {
setAberto(false)
setNome("")
setTelefone("")
setEmail("")
setErro(null)
}

function salvar() {
if (!instanciaId) {
setErro("Conecte um número em Conexões antes de criar um contato.")
return
}
if (!nome.trim() || !telefone.trim()) {
setErro("Preencha nome e telefone.")
return
}
startTransition(async () => {
const fd = new FormData()
fd.set("instancia_id", instanciaId)
fd.set("nome", nome.trim())
fd.set("telefone", telefone.trim())
fd.set("email", email.trim())
const r = await criarLeadManualAction(fd)
if (!r.ok) {
setErro(r.erro ?? "Erro ao criar contato")
return
}
fechar()
if (r.id) router.push(`/dashboard/crm?view=conversas&lead=${r.id}`)
router.refresh()
})
}
Comment on lines +47 to +71

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 | 🟠 Major | ⚡ Quick win

r.aviso is never surfaced to the user — the WhatsApp-existence warning is silently dropped.

criarLeadManualAction returns an aviso field ("Esse número não parece estar no WhatsApp — confira antes de enviar.") specifically to warn the user best-effort, but salvar() only checks r.ok/r.id and immediately closes the popover and navigates away. The entire verification feature described in the PR (warn before sending to a non-WhatsApp number) has no visible effect for the user.

🐛 Proposed fix
+  const [aviso, setAviso] = useState<string | null>(null)
+
   function salvar() {
     if (!instanciaId) {
       setErro("Conecte um número em Conexões antes de criar um contato.")
       return
     }
     if (!nome.trim() || !telefone.trim()) {
       setErro("Preencha nome e telefone.")
       return
     }
     startTransition(async () => {
       const fd = new FormData()
       fd.set("instancia_id", instanciaId)
       fd.set("nome", nome.trim())
       fd.set("telefone", telefone.trim())
       fd.set("email", email.trim())
       const r = await criarLeadManualAction(fd)
       if (!r.ok) {
         setErro(r.erro ?? "Erro ao criar contato")
         return
       }
+      if (r.aviso) setAviso(r.aviso)
       fechar()
       if (r.id) router.push(`/dashboard/crm?view=conversas&lead=${r.id}`)
       router.refresh()
     })
   }

aviso would then need to be rendered somewhere persistent (e.g. a toast, or held in the thread view via a query param) since the popover closes right after fechar().

🤖 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 `@components/crm/NovoContato.tsx` around lines 47 - 71, Update salvar() to
surface the successful response’s r.aviso before closing the popover and
navigating away, using the existing persistent notification or message mechanism
so the WhatsApp warning remains visible to the user. Preserve the current error
handling and successful lead navigation, and ensure the warning is handled
before fechar().


if (ativas.length === 0) return null

return (
<div style={{ position: "relative" }}>
<button
type="button"
onClick={() => setAberto((v) => !v)}
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--gold, #C9953A)",
padding: "4px 8px",
}}
>
+ Novo contato
</button>

{aberto && (
<>
<button
type="button"
aria-label="Fechar"
onClick={fechar}
style={{ position: "fixed", inset: 0, zIndex: 15, cursor: "default" }}
/>
<div
className="glass"
style={{
position: "absolute",
top: "100%",
left: 0,
marginTop: 6,
padding: 12,
width: 260,
zIndex: 20,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<p
style={{
fontSize: 9,
letterSpacing: "1.5px",
textTransform: "uppercase",
color: "var(--text-4)",
fontWeight: 500,
}}
>
Novo contato
</p>

{ativas.length > 1 && (
<select
value={instanciaId}
onChange={(e) => setInstanciaId(e.target.value)}
className="glass-input"
style={{ fontSize: 12, padding: "6px 8px" }}
>
{ativas.map((i) => (
<option key={i.id} value={i.id}>
{i.display_nome ?? i.empresa_slug}
{i.numero_e164 ? ` · ${i.numero_e164}` : ""}
</option>
))}
</select>
)}

<input
value={nome}
onChange={(e) => setNome(e.target.value)}
placeholder="Nome"
maxLength={80}
autoFocus
className="glass-input"
style={{ fontSize: 12, padding: "6px 8px" }}
/>
<input
value={telefone}
onChange={(e) => setTelefone(e.target.value)}
placeholder="Telefone (com DDD)"
inputMode="tel"
maxLength={20}
className="glass-input"
style={{ fontSize: 12, padding: "6px 8px" }}
/>
{/* Sem DDI (10/11 dígitos) ganha o 55 na frente — mostra o
resultado pra dar chance de corrigir um número que já veio
com código de país diferente (ex: número dos EUA também tem
11 dígitos e seria confundido com um BR sem DDI). */}
{telefone.trim() && (
<p style={{ fontSize: 10, color: "var(--text-4)", marginTop: -4 }}>
Vai salvar como: {previaTelefone(telefone)}
</p>
)}
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="E-mail (opcional)"
type="email"
maxLength={120}
className="glass-input"
style={{ fontSize: 12, padding: "6px 8px" }}
/>

{erro && <p style={{ fontSize: 11, color: "var(--danger)" }}>{erro}</p>}

<div className="flex items-center gap-2">
<button
type="button"
onClick={salvar}
disabled={pending}
className="btn-gold-filled"
style={{ fontSize: 11, padding: "5px 10px", opacity: pending ? 0.5 : 1 }}
>
{pending ? "Criando..." : "Criar"}
</button>
<button
type="button"
onClick={fechar}
style={{ fontSize: 11, color: "var(--text-3)" }}
>
Cancelar
</button>
</div>

<p style={{ fontSize: 10, color: "var(--text-4)" }}>
Só vira conversa de verdade no WhatsApp do celular depois que você
mandar a primeira mensagem.
</p>
</div>
</>
)}
</div>
)
}
Loading