From d37b3b66cddf457d541cf91f67434bff847c0246 Mon Sep 17 00:00:00 2001 From: Bruno Freitas Date: Sat, 11 Jul 2026 17:07:46 -0300 Subject: [PATCH] =?UTF-8?q?feat(crm):=20Fase=206=20=E2=80=94=20etapas=20ed?= =?UTF-8?q?it=C3=A1veis/reorden=C3=A1veis,=20etiquetas=20sincronizadas=20c?= =?UTF-8?q?om=20Kanban,=20Agendar=20move=20fase,=20corre=C3=A7=C3=A3o=20do?= =?UTF-8?q?=20layout=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige os bugs relatados no celular (teclado/composer inacessível, lista de conversas invisível, travamento ao rolar/arrastar): o CRM usava height:100vh fixo + grid de 360px+1fr sem breakpoint responsivo. No mobile isso somava com o padding-top:56px do menu hambúrguer do AppShell (~56px de conteúdo escondido abaixo da dobra) e espremia a sidebar+thread numa tela de celular. Troca pra 100dvh (dinâmico) em toda a cadeia de ancestrais (body, .app-main, CRM) e adiciona um breakpoint que mostra só a lista OU a conversa aberta abaixo de 768px, com botão de voltar. Kanban: cada coluna ganha um menu (⋮) com editar nome/cor, excluir e mover esquerda/direita — vale pra qualquer etapa, inclusive as padrão (antes só etapas próprias podiam ser excluídas). Etiquetas: deixam de ser um vocabulário livre criável em Conversas e viram um espelho das etapas do Kanban (mesmo nome/cor, sincronizado ao criar/editar/excluir etapa) — só dá pra criar fase nova pelo Kanban. Agendar (antes "Atividade"): ao marcar uma categoria que bate com o nome de uma etapa, move o lead pra essa etapa e aplica a etiqueta correspondente. Migração adiciona as 3 categorias padrão que ainda não existiam como etapa (Follow-up, Fechamento de follow-up, Fechamento). Revisão adversarial (multi-agente) encontrou e corrigiu 8 bugs reais antes do commit: erro engolido no rename em massa da etiqueta espelhada, tags de fase acumulando em vez de trocar, corrida de `ordem` na migração, exclusão de etapa destravada por engano após clicar em outra ação do menu, erro de validação preso entre aberturas do form, e o mesmo mismatch vh/dvh reaparecendo em ancestrais fora do CRM (AppShell, body). Co-Authored-By: Claude Sonnet 5 --- app/dashboard/crm/page.tsx | 18 +- app/globals.css | 46 +++ app/layout.tsx | 6 +- components/AppShell.tsx | 7 +- components/crm/Etiquetas.tsx | 63 +--- components/crm/Kanban.tsx | 301 ++++++++++++++++-- components/crm/Thread.tsx | 25 +- lib/crm-atividades-actions.ts | 82 +++++ lib/crm-etapas.ts | 51 ++- lib/crm-etiquetas-actions.ts | 119 ++----- lib/crm-kanban-actions.ts | 140 +++++++- ...fase6_etapas_reordenaveis_agendar_sync.sql | 35 ++ 12 files changed, 684 insertions(+), 209 deletions(-) create mode 100644 supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql diff --git a/app/dashboard/crm/page.tsx b/app/dashboard/crm/page.tsx index 5ac21d6..1bee757 100644 --- a/app/dashboard/crm/page.tsx +++ b/app/dashboard/crm/page.tsx @@ -70,8 +70,8 @@ export default async function CrmPage({ return (
@@ -103,17 +103,9 @@ export default async function CrmPage({
{aba === "conversas" && ( -
+
-
+
{lead ? ( - {children} + {/* min-h-dvh (não min-h-screen/100vh): em mobile, vh usa a viewport + "grande" (barra do navegador recolhida) — com a barra visível + (o estado mais comum), min-height:100vh deixa o body mais alto do + que o conteúdo, sobrando espaço em branco rolável no fim da página. */} + {children} ) } diff --git a/components/AppShell.tsx b/components/AppShell.tsx index dc8f657..a8b57af 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -196,7 +196,12 @@ export default function AppShell({ // CSS var permite que a regra mobile sobrescreva pra 0 sem // que precisemos checar matchMedia no React. ["--rail-width" as string]: `${railWidth}px`, - minHeight: "100vh", + // dvh (não vh): em mobile, vh usa a viewport "grande" (barra do + // navegador recolhida) — quando a barra está visível (o estado + // mais comum), min-height:100vh deixa esse container mais alto do + // que o conteúdo (ex: a tela cheia do CRM, que já usa dvh), + // sobrando um espaço em branco rolável no fim da página. + minHeight: "100dvh", }} > {children} diff --git a/components/crm/Etiquetas.tsx b/components/crm/Etiquetas.tsx index faa1741..844bec9 100644 --- a/components/crm/Etiquetas.tsx +++ b/components/crm/Etiquetas.tsx @@ -3,11 +3,9 @@ import { useState, useTransition } from "react" import { useRouter } from "next/navigation" import { - criarEtiquetaAction, atribuirEtiquetaAction, removerEtiquetaAction, } from "@/lib/crm-etiquetas-actions" -import { CORES_ETIQUETA } from "@/lib/crm-cores" interface EtiquetaResumo { id: string @@ -62,8 +60,9 @@ export function EtiquetaChip({ ) } -/** Popover de atribuição de etiquetas a um lead — checkboxes + criação - * inline de etiqueta nova (mesmo espírito dos labels do WhatsApp Business). */ +/** Popover de atribuição de etiquetas a um lead — checkboxes das etiquetas + * sincronizadas com as etapas do Kanban (Fase 6: não dá mais pra criar + * etiqueta nova aqui, só em Kanban → elas viram "fases"). */ export default function EtiquetasPicker({ leadId, etiquetasDoLead, @@ -74,7 +73,6 @@ export default function EtiquetasPicker({ todasEtiquetas: EtiquetaResumo[] }) { const [aberto, setAberto] = useState(false) - const [novoNome, setNovoNome] = useState("") const [pending, startTransition] = useTransition() const router = useRouter() @@ -91,26 +89,6 @@ export default function EtiquetasPicker({ }) } - function criarNova() { - const nome = novoNome.trim() - if (!nome) return - const cor = CORES_ETIQUETA[todasEtiquetas.length % CORES_ETIQUETA.length] - startTransition(async () => { - const fdCriar = new FormData() - fdCriar.set("nome", nome) - fdCriar.set("cor", cor) - const r = await criarEtiquetaAction(fdCriar) - if (r.ok && r.id) { - const fdAtribuir = new FormData() - fdAtribuir.set("lead_id", leadId) - fdAtribuir.set("etiqueta_id", r.id) - await atribuirEtiquetaAction(fdAtribuir) - } - setNovoNome("") - router.refresh() - }) - } - return (
-
- setNovoNome(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - criarNova() - } - }} - placeholder="Nova etiqueta..." - maxLength={40} - className="glass-input" - style={{ flex: 1, fontSize: 11, padding: "4px 8px" }} - /> - -
)} diff --git a/components/crm/Kanban.tsx b/components/crm/Kanban.tsx index 7c54199..eb68f4f 100644 --- a/components/crm/Kanban.tsx +++ b/components/crm/Kanban.tsx @@ -26,6 +26,8 @@ import type { CrmLeadRow } from "@/lib/crm-leads" import { moverLeadAction, criarEtapaAction, + editarEtapaAction, + moverEtapaAction, excluirEtapaAction, } from "@/lib/crm-kanban-actions" import { CORES_ETIQUETA } from "@/lib/crm-cores" @@ -178,12 +180,14 @@ export default function Kanban({ className="flex gap-3 overflow-x-auto scrollbar-thin" style={{ height: "100%", paddingBottom: 8 }} > - {etapas.map((etapa) => ( + {etapas.map((etapa, index) => ( ))} @@ -205,30 +209,16 @@ function Coluna({ etapa, leads, corPorEmpresa, + isFirst, + isLast, }: { etapa: CrmEtapaRow leads: CrmLeadRow[] corPorEmpresa: Record + isFirst: boolean + isLast: boolean }) { const { setNodeRef, isOver } = useDroppable({ id: etapa.id }) - const [pending, startTransition] = useTransition() - const [confirmando, setConfirmando] = useState(false) - const router = useRouter() - - function pedirExclusao() { - if (!confirmando) { - setConfirmando(true) - setTimeout(() => setConfirmando(false), 3000) - return - } - setConfirmando(false) - startTransition(async () => { - const fd = new FormData() - fd.set("id", etapa.id) - await excluirEtapaAction(fd) - router.refresh() - }) - } return (
{leads.length} - {etapa.propria && ( - - )} +
l.id)} strategy={verticalListSortingStrategy}> @@ -308,6 +283,258 @@ function Coluna({ ) } +/** Menu (⋮) do cabeçalho de cada coluna — editar nome/cor, mover pra + * esquerda/direita (reordena o Kanban) e excluir a fase. Vale pra QUALQUER + * etapa, inclusive as padrão (Novo/Em conversa/Qualificado/...): excluir ou + * editar uma etapa padrão afeta todo mundo que usa o CRM, não só quem + * criou (elas não têm dono). */ +function MenuEtapa({ + etapa, + isFirst, + isLast, +}: { + etapa: CrmEtapaRow + isFirst: boolean + isLast: boolean +}) { + const [aberto, setAberto] = useState(false) + const [editando, setEditando] = useState(false) + const [confirmando, setConfirmando] = useState(false) + const [nome, setNome] = useState(etapa.nome) + const [cor, setCor] = useState(etapa.cor || CORES_ETIQUETA[0]) + const [erro, setErro] = useState(null) + const [pending, startTransition] = useTransition() + const router = useRouter() + + function fechar() { + setAberto(false) + setEditando(false) + setConfirmando(false) + setErro(null) + } + + // Ressincroniza nome/cor a partir das props no momento de abrir o form — + // não em fechar(): logo após salvar, fechar() roda antes do + // router.refresh() terminar, então a prop `etapa` ainda é a versão velha; + // resetar por ali deixaria o form com o nome antigo numa próxima abertura. + // Também desarma a exclusão e limpa erro de uma tentativa anterior — senão + // "Excluir" fica armado (1 clique = exclui) ou o form reabre mostrando um + // erro de outra edição já abandonada. + function abrirEdicao() { + setNome(etapa.nome) + setCor(etapa.cor || CORES_ETIQUETA[0]) + setConfirmando(false) + setErro(null) + setEditando(true) + } + + function mover(direcao: "esquerda" | "direita") { + setConfirmando(false) + startTransition(async () => { + const fd = new FormData() + fd.set("id", etapa.id) + fd.set("direcao", direcao) + await moverEtapaAction(fd) + router.refresh() + }) + } + + function salvarEdicao() { + const n = nome.trim() + if (!n) return + startTransition(async () => { + const fd = new FormData() + fd.set("id", etapa.id) + fd.set("nome", n) + fd.set("cor", cor) + const r = await editarEtapaAction(fd) + if (r.ok) { + fechar() + router.refresh() + } else { + setErro(r.erro ?? "Erro ao salvar") + } + }) + } + + function excluir() { + if (!confirmando) { + setConfirmando(true) + setTimeout(() => setConfirmando(false), 3000) + return + } + startTransition(async () => { + const fd = new FormData() + fd.set("id", etapa.id) + await excluirEtapaAction(fd) + fechar() + router.refresh() + }) + } + + // Alterna abrir/fechar pelo próprio botão ⋮ — fechar por AQUI (em vez de + // só alternar `aberto`) importa pro caso de teclado (Tab + Enter/Espaço no + // botão): esse clique não passa pela hit-test do overlay de fundo, então é + // o único jeito de fechar que não passava por fechar() e podia deixar + // editando/confirmando/erro "presos" pra próxima abertura. + function alternar() { + if (aberto) fechar() + else setAberto(true) + } + + return ( +
+ + + {aberto && ( + <> +
+ {erro &&

{erro}

} +
+ + +
+
+ ) : ( +
+ + mover("esquerda")} + disabled={isFirst || pending} + /> + mover("direita")} + disabled={isLast || pending} + /> + + {!etapa.propria && ( +

+ Etapa compartilhada — afeta todos os usuários. +

+ )} +
+ )} +
+ + )} +
+ ) +} + +function BotaoMenu({ + label, + onClick, + disabled, + perigo, +}: { + label: string + onClick: () => void + disabled?: boolean + perigo?: boolean +}) { + return ( + + ) +} + /** Coluna especial no fim da lista: "+ Nova etapa" — abre um form inline pra * criar uma etapa custom do usuário (nome + cor). */ function NovaColuna() { @@ -423,6 +650,10 @@ function CartaoArrastavel({ lead, cor }: { lead: CrmLeadRow; cor: string }) { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, + // Sem isso, o touch-scroll nativo da coluna disputa com o dnd-kit no + // celular (o toque tenta rolar E arrastar ao mesmo tempo) — trava o + // gesto de rolar a coluna. + touchAction: "none" as const, } return (
diff --git a/components/crm/Thread.tsx b/components/crm/Thread.tsx index a7c4bcc..e80600a 100644 --- a/components/crm/Thread.tsx +++ b/components/crm/Thread.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, useTransition } from "react" import { useRouter } from "next/navigation" +import Link from "next/link" import type { CrmLeadRow, CrmMensagemRow } from "@/lib/crm-leads" import type { CrmTipoAtividadeRow } from "@/lib/crm-atividades-actions" import { @@ -163,7 +164,27 @@ export default function Thread({ }} >
- +
+ + ‹ + + +
@@ -710,7 +731,7 @@ function AtribuirAtividade({ whiteSpace: "nowrap", }} > - + Atividade + + Agendar {aberto && ( diff --git a/lib/crm-atividades-actions.ts b/lib/crm-atividades-actions.ts index f991f09..85317ed 100644 --- a/lib/crm-atividades-actions.ts +++ b/lib/crm-atividades-actions.ts @@ -2,9 +2,11 @@ import { randomBytes } from "crypto" import { revalidatePath } from "next/cache" +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" export interface CrmAtividadeRow { id: string @@ -175,10 +177,90 @@ export async function criarFollowUpAction( }) if (error) return { ok: false, erro: error.message } + // Fase 6: "Agendar" também move o lead no Kanban e aplica a etiqueta + // correspondente quando a categoria escolhida bate com o nome de uma etapa + // (as 4 categorias padrão viram etapa — ver migração da Fase 6). Tipo + // custom sem etapa correspondente só cria o agendamento mesmo, sem mover. + await sincronizarLeadComEtapaDaCategoria(db, usuario.id, leadId, categoria) + revalidatePath("/dashboard/crm") return { ok: true } } +async function sincronizarLeadComEtapaDaCategoria( + db: SupabaseClient, + usuarioId: string, + leadId: string, + categoria: string +): Promise { + const { data: etapas } = await db + .from("crm_etapas") + .select("id, nome, tipo, cor") + .eq("ativo", true) + .or(`usuario_id.is.null,usuario_id.eq.${usuarioId}`) + const etapa = (etapas ?? []).find( + (e: Record) => (e.nome as string).toLowerCase() === categoria.trim().toLowerCase() + ) + 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 = { + 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) + } +} + /** Tipos de atividade CUSTOM do usuario (alem dos 4 padrao, que ficam em * lib/crm-tipos-atividade.ts). */ export async function listarTiposAtividade(): Promise { diff --git a/lib/crm-etapas.ts b/lib/crm-etapas.ts index 14e7bcc..b9ebc3e 100644 --- a/lib/crm-etapas.ts +++ b/lib/crm-etapas.ts @@ -1,9 +1,17 @@ -// Etapas do Kanban — vocabulário COMPARTILHADO por padrão (as 7 etapas +// Etapas do Kanban — vocabulário COMPARTILHADO por padrão (as etapas // padrão, mesmas pra todo mundo; só os LEADS dentro delas são isolados por // usuário). Fase 5: cada usuário também pode criar as PRÓPRIAS etapas // (usuario_id preenchido), visíveis só pra ele — mesmo espírito de // crm_tipos_atividade (padrão global + custom por usuário). Server-only. +// +// Fase 6: etiquetas (crm_etiquetas) viram um ESPELHO das etapas — cada etapa +// visível ao usuário tem uma etiqueta correspondente (mesmo nome/cor), +// vinculada por etapa_id. sincronizarEtiquetaDaEtapa faz esse elo (get-or- +// create-or-relink); chamada tanto ao criar/editar etapa quanto ao ler a +// lista de etiquetas (lib/crm-etiquetas-actions.ts), o que também cobre +// usuários novos sem precisar de backfill. +import type { SupabaseClient } from "@supabase/supabase-js" import { getSupabaseAdmin } from "./supabase" import { getUsuarioAtual } from "./auth" @@ -46,3 +54,44 @@ export async function listarEtapas(): Promise { propria: row.usuario_id === usuario?.id, })) } + +/** + * Garante que existe uma etiqueta do usuário espelhando esta etapa — + * cria se não existir, ou religa uma etiqueta já existente com o MESMO nome + * (ex: uma tag manual "FOLLOW-UP" criada antes da Fase 6 passa a apontar pra + * etapa "Follow-up" em vez de duplicar, respeitando a unicidade por + * usuario_id+nome). Retorna o id da etiqueta, ou null se não foi possível. + */ +export async function sincronizarEtiquetaDaEtapa( + db: SupabaseClient, + usuarioId: string, + etapa: { id: string; nome: string; cor: string | null } +): Promise { + const cor = etapa.cor ?? "#8e7cc3" + const { data: minhas } = await db + .from("crm_etiquetas") + .select("id, nome, etapa_id") + .eq("usuario_id", usuarioId) + const existente = (minhas ?? []).find( + (e: Record) => (e.nome as string).toLowerCase() === etapa.nome.toLowerCase() + ) + if (existente) { + if (existente.etapa_id !== etapa.id) { + await db + .from("crm_etiquetas") + .update({ etapa_id: etapa.id, cor }) + .eq("id", existente.id) + } + return existente.id as string + } + const { data: criada, error } = await db + .from("crm_etiquetas") + .insert({ usuario_id: usuarioId, nome: etapa.nome, cor, etapa_id: etapa.id }) + .select("id") + .single() + if (error) { + console.error("[crm_etiquetas] sync error", error.message) + return null + } + return (criada?.id as string) ?? null +} diff --git a/lib/crm-etiquetas-actions.ts b/lib/crm-etiquetas-actions.ts index 9a35329..5d6116d 100644 --- a/lib/crm-etiquetas-actions.ts +++ b/lib/crm-etiquetas-actions.ts @@ -4,120 +4,67 @@ import { revalidatePath } from "next/cache" import { getSupabaseAdmin } from "./supabase" import { getUsuarioAtual } from "./auth" import type { CrmEtiquetaResumo } from "./crm-leads" -import { CORES_ETIQUETA } from "./crm-cores" +import { sincronizarEtiquetaDaEtapa } from "./crm-etapas" export interface ResultadoEtiqueta { ok: boolean erro?: string } -function corValida(bruto: string | null): string | null { - if (!bruto) return null - return /^#[0-9a-fA-F]{6}$/.test(bruto) ? bruto : null -} - function revalidarCrm() { revalidatePath("/dashboard/crm") } -/** Etiquetas do usuário logado (vocabulário pessoal, igual labels do - * WhatsApp Business). */ +/** + * Etiquetas do usuário logado — Fase 6: deixaram de ser um vocabulário livre + * (criado em Conversas) e viraram um ESPELHO das etapas do Kanban visíveis a + * ele (padrão + próprias), pra funcionar como as mesmas "fases". Criação/ + * edição/exclusão só acontece via etapa (Kanban); aqui só garantimos (self- + * healing) que toda etapa visível tem uma etiqueta correspondente — cobre + * também usuários novos, sem depender de backfill em migração. + */ export async function listarEtiquetas(): Promise { const usuario = await getUsuarioAtual() if (!usuario) return [] const db = getSupabaseAdmin() if (!db) return [] - const { data, error } = await db - .from("crm_etiquetas") + + const { data: etapas } = await db + .from("crm_etapas") .select("id, nome, cor") + .eq("ativo", true) + .or(`usuario_id.is.null,usuario_id.eq.${usuario.id}`) + if (!etapas || etapas.length === 0) return [] + + const { data: existentes, error } = await db + .from("crm_etiquetas") + .select("id, nome, cor, etapa_id") .eq("usuario_id", usuario.id) - .order("nome", { ascending: true }) if (error) { console.error("[crm_etiquetas] list error", error.message) return [] } - return (data ?? []) as CrmEtiquetaResumo[] -} - -export async function criarEtiquetaAction( - formData: FormData -): Promise { - const usuario = await getUsuarioAtual() - if (!usuario) return { ok: false, erro: "Sessão expirada." } - const db = getSupabaseAdmin() - if (!db) return { ok: false, erro: "Supabase indisponível." } - const nome = String(formData.get("nome") ?? "").trim() - const cor = corValida(String(formData.get("cor") ?? "")) ?? CORES_ETIQUETA[0] - if (!nome) return { ok: false, erro: "Nome da etiqueta obrigatório." } + const idsEtapasComEtiqueta = new Set((existentes ?? []).map((e) => e.etapa_id)) + const faltando = etapas.filter((et) => !idsEtapasComEtiqueta.has(et.id)) - const { data, error } = await db - .from("crm_etiquetas") - .insert({ usuario_id: usuario.id, nome, cor }) - .select("id") - .single() - if (error) { - const jaExiste = error.code === "23505" - return { - ok: false, - erro: jaExiste ? `Você já tem uma etiqueta "${nome}".` : error.message, - } + if (faltando.length === 0) { + return (existentes ?? []) + .filter((e) => e.etapa_id) + .map((e) => ({ id: e.id as string, nome: e.nome as string, cor: e.cor as string })) } - revalidarCrm() - return { ok: true, id: data?.id as string } -} - -export async function editarEtiquetaAction( - formData: FormData -): Promise { - const usuario = await getUsuarioAtual() - if (!usuario) return { ok: false, erro: "Sessão expirada." } - const db = getSupabaseAdmin() - if (!db) return { ok: false, erro: "Supabase indisponível." } - - const id = String(formData.get("id") ?? "").trim() - const nome = String(formData.get("nome") ?? "").trim() - const cor = corValida(String(formData.get("cor") ?? "")) - if (!id) return { ok: false, erro: "ID inválido." } - if (!nome) return { ok: false, erro: "Nome da etiqueta obrigatório." } + // Em paralelo (não sequencial) — cada etapa faltante é independente, e + // isso importa principalmente no primeiro carregamento de um usuário novo + // (várias etapas padrão pra sincronizar de uma vez só). + await Promise.all(faltando.map((etapa) => sincronizarEtiquetaDaEtapa(db, usuario.id, etapa))) - const patch: Record = { nome } - if (cor) patch.cor = cor - - const { error } = await db + const { data: final } = await db .from("crm_etiquetas") - .update(patch) - .eq("id", id) - .eq("usuario_id", usuario.id) - if (error) return { ok: false, erro: error.message } - - revalidarCrm() - return { ok: true } -} - -export async function excluirEtiquetaAction( - formData: FormData -): Promise { - const usuario = await getUsuarioAtual() - if (!usuario) return { ok: false, erro: "Sessão expirada." } - const db = getSupabaseAdmin() - if (!db) return { ok: false, erro: "Supabase indisponível." } - - const id = String(formData.get("id") ?? "").trim() - if (!id) return { ok: false, erro: "ID inválido." } - - // crm_lead_etiquetas tem ON DELETE CASCADE — apagar a etiqueta já remove - // as atribuições nos leads automaticamente. - const { error } = await db - .from("crm_etiquetas") - .delete() - .eq("id", id) + .select("id, nome, cor") .eq("usuario_id", usuario.id) - if (error) return { ok: false, erro: error.message } - - revalidarCrm() - return { ok: true } + .not("etapa_id", "is", null) + return (final ?? []) as CrmEtiquetaResumo[] } export async function atribuirEtiquetaAction( diff --git a/lib/crm-kanban-actions.ts b/lib/crm-kanban-actions.ts index eb984b5..371bd5b 100644 --- a/lib/crm-kanban-actions.ts +++ b/lib/crm-kanban-actions.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache" import { getSupabaseAdmin } from "./supabase" import { getUsuarioAtual } from "./auth" +import { listarEtapas, sincronizarEtiquetaDaEtapa } from "./crm-etapas" export interface ResultadoMoverLead { ok: boolean @@ -90,6 +91,13 @@ export async function criarEtapaAction( const cor = corValida(String(formData.get("cor") ?? "")) ?? "#C9953A" if (!nome) return { ok: false, erro: "Nome da etapa obrigatório." } + // Nome único entre as etapas visíveis ao usuário — evita duas colunas com + // o mesmo nome, o que colidiria na etiqueta espelhada (única por + // usuario_id+nome, ver sincronizarEtiquetaDaEtapa). + const visiveis = await listarEtapas() + const colisao = visiveis.find((e) => e.nome.toLowerCase() === nome.toLowerCase()) + if (colisao) return { ok: false, erro: `Já existe uma etapa "${nome}".` } + const { data: ultima } = await db .from("crm_etapas") .select("ordem") @@ -113,14 +121,17 @@ export async function criarEtapaAction( .single() if (error) return { ok: false, erro: error.message } + const etapaId = data?.id as string + await sincronizarEtiquetaDaEtapa(db, usuario.id, { id: etapaId, nome, cor }) + revalidatePath("/dashboard/crm") - return { ok: true, id: data?.id as string } + return { ok: true, id: etapaId } } -/** Remove uma etapa CUSTOM do próprio usuário (não mexe nas 7 padrão — essas - * não têm dono, ninguém apaga pela UI). Leads que estavam nela voltam pra - * "sem etapa" (etapa_id null) em vez de sumir. */ -export async function excluirEtapaAction( +/** Renomeia/recolore uma etapa — padrão (compartilhada) ou própria. O nome + * novo propaga pra etiqueta espelhada de TODO usuário que a enxerga (a + * etapa é uma só; a etiqueta é 1 cópia por usuário). */ +export async function editarEtapaAction( formData: FormData ): Promise { const usuario = await getUsuarioAtual() @@ -129,7 +140,10 @@ export async function excluirEtapaAction( if (!db) return { ok: false, erro: "Supabase indisponível." } const id = String(formData.get("id") ?? "").trim() + const nome = String(formData.get("nome") ?? "").trim().slice(0, 60) + const cor = corValida(String(formData.get("cor") ?? "")) if (!id) return { ok: false, erro: "ID inválido." } + if (!nome) return { ok: false, erro: "Nome da etapa obrigatório." } const { data: etapa } = await db .from("crm_etapas") @@ -137,15 +151,119 @@ export async function excluirEtapaAction( .eq("id", id) .maybeSingle() if (!etapa) return { ok: false, erro: "Etapa não encontrada." } - if (etapa.usuario_id !== usuario.id) { - return { ok: false, erro: "Só é possível excluir etapas criadas por você." } + if (etapa.usuario_id && etapa.usuario_id !== usuario.id) { + return { ok: false, erro: "Essa etapa não é sua." } } - await db - .from("crm_leads") - .update({ etapa_id: null }) + const visiveis = await listarEtapas() + const colisao = visiveis.find( + (e) => e.id !== id && e.nome.toLowerCase() === nome.toLowerCase() + ) + if (colisao) return { ok: false, erro: `Já existe uma etapa "${nome}".` } + + const patch: Record = { nome } + if (cor) patch.cor = cor + + const { error } = await db.from("crm_etapas").update(patch).eq("id", id) + if (error) return { ok: false, erro: error.message } + + const patchEtiqueta: Record = { nome } + if (cor) patchEtiqueta.cor = cor + const { error: erroEtiqueta } = await db + .from("crm_etiquetas") + .update(patchEtiqueta) .eq("etapa_id", id) - .eq("usuario_id", usuario.id) + if (erroEtiqueta) { + // A etapa já foi renomeada (statement acima) — isso só pode falhar por + // colisão de nome na etiqueta espelhada de outro usuário (unique por + // usuario_id+nome), que não dá pra prever aqui (não enxergamos etapas + // privadas de outros usuários). Reporta em vez de mentir "ok" com o + // espelho dessincronizado. + return { + ok: false, + erro: `Etapa renomeada, mas a etiqueta de algum usuário colidiu com outro nome já existente: ${erroEtiqueta.message}`, + } + } + + revalidatePath("/dashboard/crm") + return { ok: true } +} + +/** Move uma etapa um passo pra esquerda/direita na ordem do Kanban, trocando + * a `ordem` com a vizinha (visível ao usuário — nunca vê etapa própria de + * outro usuário, então a troca é sempre seguro). */ +export async function moverEtapaAction( + formData: FormData +): Promise { + const usuario = await getUsuarioAtual() + if (!usuario) return { ok: false, erro: "Sessão expirada." } + const db = getSupabaseAdmin() + if (!db) return { ok: false, erro: "Supabase indisponível." } + + const id = String(formData.get("id") ?? "").trim() + const direcao = String(formData.get("direcao") ?? "") + if (!id) return { ok: false, erro: "ID inválido." } + if (direcao !== "esquerda" && direcao !== "direita") { + return { ok: false, erro: "Direção inválida." } + } + + const visiveis = await listarEtapas() + const idx = visiveis.findIndex((e) => e.id === id) + if (idx === -1) return { ok: false, erro: "Etapa não encontrada." } + const idxVizinha = direcao === "esquerda" ? idx - 1 : idx + 1 + if (idxVizinha < 0 || idxVizinha >= visiveis.length) { + return { ok: true } // já está na ponta — no-op silencioso + } + + const atual = visiveis[idx] + const vizinha = visiveis[idxVizinha] + + const [{ error: e1 }, { error: e2 }] = await Promise.all([ + db.from("crm_etapas").update({ ordem: vizinha.ordem }).eq("id", atual.id), + db.from("crm_etapas").update({ ordem: atual.ordem }).eq("id", vizinha.id), + ]) + if (e1 || e2) return { ok: false, erro: (e1 ?? e2)?.message } + + revalidatePath("/dashboard/crm") + return { ok: true } +} + +/** Remove uma etapa — padrão (compartilhada, afeta todo mundo) ou própria. + * Leads que estavam nela voltam pra "sem etapa" (etapa_id null) em vez de + * sumir; etiquetas espelhadas somem junto (ON DELETE CASCADE). */ +export async function excluirEtapaAction( + formData: FormData +): Promise { + const usuario = await getUsuarioAtual() + if (!usuario) return { ok: false, erro: "Sessão expirada." } + const db = getSupabaseAdmin() + if (!db) return { ok: false, erro: "Supabase indisponível." } + + const id = String(formData.get("id") ?? "").trim() + if (!id) return { ok: false, erro: "ID inválido." } + + const { data: etapa } = await db + .from("crm_etapas") + .select("id, usuario_id") + .eq("id", id) + .maybeSingle() + if (!etapa) return { ok: false, erro: "Etapa não encontrada." } + if (etapa.usuario_id && etapa.usuario_id !== usuario.id) { + return { ok: false, erro: "Essa etapa não é sua." } + } + + // Etapa padrão (usuario_id null) é compartilhada — leads de QUALQUER dono + // podem estar nela, então o reset não filtra por usuario_id. Etapa própria + // só tem leads do próprio dono (moverLeadAction já garante isso). + if (etapa.usuario_id) { + await db + .from("crm_leads") + .update({ etapa_id: null }) + .eq("etapa_id", id) + .eq("usuario_id", usuario.id) + } else { + await db.from("crm_leads").update({ etapa_id: null }).eq("etapa_id", id) + } const { error } = await db.from("crm_etapas").delete().eq("id", id) if (error) return { ok: false, erro: error.message } diff --git a/supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql b/supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql new file mode 100644 index 0000000..5e91644 --- /dev/null +++ b/supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql @@ -0,0 +1,35 @@ +-- CRM — Fase 6: etapas com nome/cor editáveis + reordenáveis, etiquetas +-- sincronizadas com o Kanban (deixam de ser criadas em Conversas), e as +-- categorias padrão de agendamento também viram etapa. 2026-07-14. Idempotente. +-- +-- Etiquetas (crm_etiquetas) passam a ser um ESPELHO de crm_etapas: cada +-- etiqueta disponível pra atribuir a um lead corresponde a uma etapa visível +-- ao usuário (padrão ou própria). O vínculo é feito por etapa_id — a +-- sincronização em si (criar/religar/renomear) acontece na aplicação +-- (lib/crm-etapas.ts, lib/crm-etiquetas-actions.ts), não aqui; esta migração +-- só prepara o schema. + +alter table public.crm_etiquetas + add column if not exists etapa_id uuid references public.crm_etapas(id) on delete cascade; +create index if not exists crm_etiquetas_etapa_idx on public.crm_etiquetas (etapa_id); + +-- As 3 categorias padrão de agendamento (lib/crm-tipos-atividade.ts) que +-- ainda não existem como etapa — "Reunião agendada" já existe com o mesmo +-- nome desde a Fase 0, não precisa duplicar. `ordem` é calculada (max atual + +-- 1/2/3) em vez de fixa: valores fixos (5/6/7) colidiriam em silêncio com uma +-- etapa que um usuário já tenha movido pra ali via o novo botão de reordenar +-- (crm_etapas.ordem não é unique — duas etapas empatadas ficam com ordem +-- indefinida entre si). Ficam no fim da lista; dá pra reordenar pela UI depois. +insert into public.crm_etapas (nome, ordem, tipo, cor) +select v.nome, + (select coalesce(max(e2.ordem), 0) from public.crm_etapas e2 where e2.ativo) + v.deslocamento, + v.tipo, v.cor +from (values + ('Follow-up', 1, 'aberta', '#4a90d9'), + ('Fechamento de follow-up', 2, 'aberta', '#8e7cc3'), + ('Fechamento', 3, 'aberta', '#4caf50') +) as v(nome, deslocamento, tipo, cor) +where not exists ( + select 1 from public.crm_etapas e + where e.usuario_id is null and lower(e.nome) = lower(v.nome) +);