Skip to content

feat(crm): Fase 6 — etapas editáveis/reordenáveis, etiquetas sincroni…#54

Merged
sejaanomalo-web merged 1 commit into
mainfrom
claude/crm-fase6-etapas-etiquetas-agendar-mobile-fix
Jul 11, 2026
Merged

feat(crm): Fase 6 — etapas editáveis/reordenáveis, etiquetas sincroni…#54
sejaanomalo-web merged 1 commit into
mainfrom
claude/crm-fase6-etapas-etiquetas-agendar-mobile-fix

Conversation

@sejaanomalo-web

@sejaanomalo-web sejaanomalo-web commented Jul 11, 2026

Copy link
Copy Markdown
Owner

…zadas com Kanban, Agendar move fase, correção do layout mobile

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).

Summary by CodeRabbit

  • New Features

    • Added Kanban phase menus for editing, reordering, and deleting phases.
    • Added mobile navigation between conversation lists and message threads.
    • Improved drag-and-drop behavior on touch devices.
    • Scheduling follow-ups now synchronizes the lead’s phase and label automatically.
  • Bug Fixes

    • Improved CRM responsiveness across desktop and mobile screen sizes.
    • Prevented duplicate phase names and improved phase ordering safeguards.
  • Changes

    • Labels now reflect Kanban phases and can be assigned or removed from leads.

…zadas com Kanban, Agendar move fase, correção do layout mobile

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
metas-anomalo Ready Ready Preview, Comment Jul 11, 2026 8:17pm

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

CRM now uses dynamic viewport-aware layouts, responsive conversation panes, Kanban stage menus, stage-label mirroring, and follow-up synchronization that updates lead stages, statuses, ordering, and labels.

Changes

CRM interface

Layer / File(s) Summary
Responsive CRM viewport layout
app/layout.tsx, components/AppShell.tsx, app/dashboard/crm/page.tsx, app/globals.css
CRM containers use dynamic viewport sizing and responsive conversation grid panes, including mobile pane visibility rules.
Conversation pane navigation
components/crm/Thread.tsx
Conversation threads include mobile back navigation, and the activity action is labeled “Agendar”.

CRM stage workflows

Layer / File(s) Summary
Stage-label synchronization contract
supabase/migrations/..., lib/crm-etapas.ts, lib/crm-etiquetas-actions.ts, components/crm/Etiquetas.tsx
Labels gain stage references, visible stages self-synchronize missing labels, and the picker only assigns or removes existing labels.
Kanban stage lifecycle controls
lib/crm-kanban-actions.ts, components/crm/Kanban.tsx
Stages support case-insensitive uniqueness checks, editing, movement, deletion, mirrored labels, dropdown controls, and touch-safe dragging.
Follow-up stage synchronization
lib/crm-atividades-actions.ts
Follow-up creation maps categories to stages, updates lead ordering and status, and synchronizes stage label associations.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main CRM Phase 6 changes: editable/reorderable stages and label synchronization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/crm-fase6-etapas-etiquetas-agendar-mobile-fix

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql (1)

12-14: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider lock-safer FK/index creation for larger tables.

Adding the FK inline and a non-concurrent index will each take a SHARE ROW EXCLUSIVE/write-blocking lock while scanning crm_etiquetas. If this table has meaningful row counts already, split into NOT VALID + separate VALIDATE CONSTRAINT, and use CREATE INDEX CONCURRENTLY (note: the latter can't run inside the same transaction as other DDL, so it typically needs its own migration/transaction).

💡 Suggested split
-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);
+alter table public.crm_etiquetas
+  add column if not exists etapa_id uuid references public.crm_etapas(id) on delete cascade not valid;
+alter table public.crm_etiquetas
+  validate constraint crm_etiquetas_etapa_id_fkey;
+-- run in a separate migration/transaction:
+-- create index concurrently if not exists crm_etiquetas_etapa_idx on public.crm_etiquetas (etapa_id);
🤖 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 `@supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql`
around lines 12 - 14, Update the crm_etiquetas schema change to add the etapa_id
foreign key as NOT VALID, then validate it in a separate lock-safer step.
Replace the regular index creation with CREATE INDEX CONCURRENTLY, separating it
into its own migration or non-transactional execution because it cannot run in
the same transaction as the other DDL.

Source: Linters/SAST tools

lib/crm-etapas.ts (1)

65-97: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Check-then-insert race on the mirrored label.

Two concurrent calls syncing the same (usuario_id, nome) pair (e.g. two tabs, or listarEtiquetas() firing twice in quick succession) can both miss each other in the minhas read and race on the insert; the loser hits the unique-constraint error, logs, and returns null — silently leaving that stage without a label until the next sync. Using upsert on the existing (usuario_id, nome) unique constraint removes the race window.

♻️ Suggested upsert
-  const { data: criada, error } = await db
-    .from("crm_etiquetas")
-    .insert({ usuario_id: usuarioId, nome: etapa.nome, cor, etapa_id: etapa.id })
-    .select("id")
-    .single()
+  const { data: criada, error } = await db
+    .from("crm_etiquetas")
+    .upsert(
+      { usuario_id: usuarioId, nome: etapa.nome, cor, etapa_id: etapa.id },
+      { onConflict: "usuario_id,nome" }
+    )
+    .select("id")
+    .single()

Please confirm the exact unique constraint columns on crm_etiquetas (referenced only in comments, not shown in this migration) before applying onConflict.

🤖 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 65 - 97, Update sincronizarEtiquetaDaEtapa to
replace the check-then-insert path with an upsert keyed by the confirmed unique
constraint columns for crm_etiquetas, using (usuario_id, nome) only after
verifying that exact database constraint. Preserve the existing etapa_id and cor
values, return the upserted label id, and retain null/error handling for genuine
upsert failures.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@components/crm/Kanban.tsx`:
- Around line 331-373: Update mover() and excluir() to inspect the results
returned by moverEtapaAction and excluirEtapaAction before refreshing or closing
the UI. On failure, preserve the current menu state as appropriate and call
setErro with the returned error or a fallback message, matching salvarEdicao();
only close and refresh after a successful action.

In `@lib/crm-atividades-actions.ts`:
- Around line 180-262: Update sincronizarLeadComEtapaDaCategoria to return a
success indicator instead of void, reporting failure when the lead update or tag
synchronization fails while preserving the existing console errors. In
criarFollowUpAction, capture that result and return the existing successful
follow-up response with a soft warning when synchronization fails, without
treating the already-created follow-up as failed.

In `@lib/crm-etiquetas-actions.ts`:
- Around line 62-67: Update the final refetch in the surrounding action to
destructure and handle its Supabase error like the earlier queries: log or
propagate the error before applying the fallback, and only return the cast
`final ?? []` on a successful query.

In `@lib/crm-kanban-actions.ts`:
- Around line 143-190: Make the rename operation in the surrounding action
atomic across crm_etapas and crm_etiquetas, preferably by replacing the separate
updates with a single db.rpc transaction that applies both patches and rolls
back on any mirrored-label conflict. Preserve the existing validation and error
reporting, and only return success after both updates commit.
- Around line 94-99: Ajuste criarEtapaAction e editarEtapaAction para depender
também de uma restrição única no banco sobre lower(nome) em public.crm_etapas,
tratando violações como erro de nome duplicado; mantenha a validação prévia
apenas como otimização. Torne a renomeação em editarEtapaAction atômica,
executando a atualização de crm_etapas e das etiquetas espelhadas na mesma
transação ou operação RPC, com rollback se qualquer gravação falhar.

---

Nitpick comments:
In `@lib/crm-etapas.ts`:
- Around line 65-97: Update sincronizarEtiquetaDaEtapa to replace the
check-then-insert path with an upsert keyed by the confirmed unique constraint
columns for crm_etiquetas, using (usuario_id, nome) only after verifying that
exact database constraint. Preserve the existing etapa_id and cor values, return
the upserted label id, and retain null/error handling for genuine upsert
failures.

In `@supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql`:
- Around line 12-14: Update the crm_etiquetas schema change to add the etapa_id
foreign key as NOT VALID, then validate it in a separate lock-safer step.
Replace the regular index creation with CREATE INDEX CONCURRENTLY, separating it
into its own migration or non-transactional execution because it cannot run in
the same transaction as the other DDL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0562e104-3645-45bd-8355-76a5f9ffbb4e

📥 Commits

Reviewing files that changed from the base of the PR and between e800d9f and d37b3b6.

📒 Files selected for processing (12)
  • app/dashboard/crm/page.tsx
  • app/globals.css
  • app/layout.tsx
  • components/AppShell.tsx
  • components/crm/Etiquetas.tsx
  • components/crm/Kanban.tsx
  • components/crm/Thread.tsx
  • lib/crm-atividades-actions.ts
  • lib/crm-etapas.ts
  • lib/crm-etiquetas-actions.ts
  • lib/crm-kanban-actions.ts
  • supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql

Comment thread components/crm/Kanban.tsx
Comment on lines +331 to +373
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()
})
}

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

mover() and excluir() don't surface failures.

Both call the server action and unconditionally proceed (excluir() even calls fechar() before checking the result), unlike salvarEdicao() which checks r.ok and shows erro. A failed delete closes the menu with no feedback, making the user think the stage was removed when it wasn't.

🐛 Suggested fix for excluir()
     startTransition(async () => {
       const fd = new FormData()
       fd.set("id", etapa.id)
-      await excluirEtapaAction(fd)
-      fechar()
-      router.refresh()
+      const r = await excluirEtapaAction(fd)
+      if (r.ok) {
+        fechar()
+        router.refresh()
+      } else {
+        setErro(r.erro ?? "Erro ao excluir")
+      }
     })
📝 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
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()
})
}
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)
const r = await excluirEtapaAction(fd)
if (r.ok) {
fechar()
router.refresh()
} else {
setErro(r.erro ?? "Erro ao excluir")
}
})
}
🤖 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/Kanban.tsx` around lines 331 - 373, Update mover() and
excluir() to inspect the results returned by moverEtapaAction and
excluirEtapaAction before refreshing or closing the UI. On failure, preserve the
current menu state as appropriate and call setErro with the returned error or a
fallback message, matching salvarEdicao(); only close and refresh after a
successful action.

Comment on lines +180 to +262
// 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<void> {
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<string, any>) => (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<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)
}
}

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

Sync failures are invisible to the caller.

sincronizarLeadComEtapaDaCategoria only console.errors on failure (Line 231-234, 259-261) and returns void; criarFollowUpAction always returns { ok: true } regardless. If the lead-move or tag-sync fails, the user is told the follow-up succeeded but the lead never actually moves/relabels in the Kanban — with no signal beyond a server log line.

Consider having the sync helper return a success flag and surfacing a soft warning (without failing the overall action, since the follow-up itself was created) so the UI/user can know the move didn't apply.

🤖 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-atividades-actions.ts` around lines 180 - 262, Update
sincronizarLeadComEtapaDaCategoria to return a success indicator instead of
void, reporting failure when the lead update or tag synchronization fails while
preserving the existing console errors. In criarFollowUpAction, capture that
result and return the existing successful follow-up response with a soft warning
when synchronization fails, without treating the already-created follow-up as
failed.

Comment on lines +62 to +67
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<ResultadoEtiqueta> {
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[]

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

Final refetch swallows errors silently.

Unlike the earlier queries in this function, the refetch after sync doesn't check error — a transient failure here returns [] (via final ?? []) without logging, making the label picker look like there are no stages at all when there actually are.

🐛 Suggested fix
-  const { data: final } = await db
+  const { data: final, error: erroFinal } = await db
     .from("crm_etiquetas")
     .select("id, nome, cor")
     .eq("usuario_id", usuario.id)
     .not("etapa_id", "is", null)
+  if (erroFinal) console.error("[crm_etiquetas] list error", erroFinal.message)
   return (final ?? []) as CrmEtiquetaResumo[]
📝 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
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<ResultadoEtiqueta> {
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[]
const { data: final, error: erroFinal } = await db
.from("crm_etiquetas")
.select("id, nome, cor")
.eq("usuario_id", usuario.id)
.not("etapa_id", "is", null)
if (erroFinal) console.error("[crm_etiquetas] list error", erroFinal.message)
return (final ?? []) as CrmEtiquetaResumo[]
🤖 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 62 - 67, Update the final refetch
in the surrounding action to destructure and handle its Supabase error like the
earlier queries: log or propagate the error before applying the fallback, and
only return the cast `final ?? []` on a successful query.

Comment thread lib/crm-kanban-actions.ts
Comment on lines +94 to +99
// 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}".` }

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -i 'crm_etapas' supabase/migrations -A5 | rg -i 'unique|primary key'

Repository: sejaanomalo-web/metas-anomalo

Length of output: 510


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files 'lib/crm-kanban-actions.ts' 'supabase/migrations/*crm_fase6*' 'supabase/migrations/*crm_fase0*'

echo
echo "== outline lib/crm-kanban-actions.ts =="
ast-grep outline lib/crm-kanban-actions.ts --view expanded || true

echo
echo "== relevant snippets from lib/crm-kanban-actions.ts =="
sed -n '1,260p' lib/crm-kanban-actions.ts | nl -ba | sed -n '70,230p'

echo
echo "== search for unique constraints on crm_etapas / crm_etiquetas =="
rg -n -i 'crm_etapas|crm_etiquetas' supabase/migrations -A4 -B2 | rg -i 'unique|primary key|constraint|index|alter table|create unique'

echo
echo "== migration containing crm_etapas table definition =="
sed -n '1,220p' supabase/migrations/20260624_crm_fase0.sql | nl -ba | sed -n '1,220p'

echo
echo "== crm fase 6 migration =="
sed -n '1,240p' supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql | nl -ba | sed -n '1,240p'

Repository: sejaanomalo-web/metas-anomalo

Length of output: 959


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('lib/crm-kanban-actions.ts')
text = p.read_text()
for needle in ['listarEtapas', 'criarEtapaAction', 'editarEtapaAction', 'sincronizarEtiquetaDaEtapa']:
    idx = text.find(needle)
    print(f"\n== {needle} @ {idx} ==")
    if idx != -1:
        start = max(0, text.rfind('\n', 0, idx-400))
        end = min(len(text), text.find('\n', idx+1200))
        snippet = text[start:end]
        for i, line in enumerate(snippet.splitlines(), 1):
            print(f"{i:4d}: {line}")
PY

Repository: sejaanomalo-web/metas-anomalo

Length of output: 4196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== lib/crm-kanban-actions.ts around the stage actions =="
sed -n '82,210p' lib/crm-kanban-actions.ts | cat -n

echo
echo "== migration: crm_etapas / crm_etiquetas table definitions and constraints =="
sed -n '1,220p' supabase/migrations/20260624_crm_fase0.sql | cat -n

echo
echo "== migration: crm fase 6 sync behavior =="
sed -n '1,260p' supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql | cat -n

Repository: sejaanomalo-web/metas-anomalo

Length of output: 20215


Add a DB-level uniqueness guard and make the rename atomic.

criarEtapaAction and editarEtapaAction still rely on a read-then-write name check, so concurrent requests can create duplicate etapa names because public.crm_etapas has no unique constraint on nome/lower(nome).
editarEtapaAction also updates crm_etapas and crm_etiquetas in separate statements; if the second write fails, the etapa stays renamed while the mirrored labels do not, leaving the tables out of sync.

🤖 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-kanban-actions.ts` around lines 94 - 99, Ajuste criarEtapaAction e
editarEtapaAction para depender também de uma restrição única no banco sobre
lower(nome) em public.crm_etapas, tratando violações como erro de nome
duplicado; mantenha a validação prévia apenas como otimização. Torne a
renomeação em editarEtapaAction atômica, executando a atualização de crm_etapas
e das etiquetas espelhadas na mesma transação ou operação RPC, com rollback se
qualquer gravação falhar.

Comment thread lib/crm-kanban-actions.ts
Comment on lines +143 to +190
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")
.select("id, usuario_id")
.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<string, unknown> = { 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<string, unknown> = { 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 }
}

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 | 🏗️ Heavy lift

Rename isn't atomic across crm_etapas and crm_etiquetas.

The stage rename (Line 167) and the mirrored-label rename (Line 172-175) are two independent statements. For a SHARED stage, the second update touches every user's mirrored label in one statement; if it fails because of one unrelated user's own naming collision, the whole label update aborts and { ok: false } is returned — but the crm_etapas rename on Line 167 has already committed. The result: the stage is renamed for everyone, the error message tells the acting user it "may have failed," and the mirrored labels are left desynced with no automatic reconciliation path, blocking future renames of that shared stage until the unrelated conflict is manually resolved.

Wrapping both updates in a single Postgres function (db.rpc(...)) would make this atomic; short of that, consider at least reverting the crm_etapas patch when the label update fails.

🤖 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-kanban-actions.ts` around lines 143 - 190, Make the rename operation
in the surrounding action atomic across crm_etapas and crm_etiquetas, preferably
by replacing the separate updates with a single db.rpc transaction that applies
both patches and rolls back on any mirrored-label conflict. Preserve the
existing validation and error reporting, and only return success after both
updates commit.

@sejaanomalo-web
sejaanomalo-web merged commit 3a3d28f into main Jul 11, 2026
3 checks passed
@sejaanomalo-web
sejaanomalo-web deleted the claude/crm-fase6-etapas-etiquetas-agendar-mobile-fix branch July 11, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant