feat(crm): Fase 6 — etapas editáveis/reordenáveis, etiquetas sincroni…#54
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughCRM 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. ChangesCRM interface
CRM stage workflows
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
supabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql (1)
12-14: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winConsider 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 scanningcrm_etiquetas. If this table has meaningful row counts already, split intoNOT VALID+ separateVALIDATE CONSTRAINT, and useCREATE 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 winCheck-then-insert race on the mirrored label.
Two concurrent calls syncing the same
(usuario_id, nome)pair (e.g. two tabs, orlistarEtiquetas()firing twice in quick succession) can both miss each other in theminhasread and race on theinsert; the loser hits the unique-constraint error, logs, and returnsnull— silently leaving that stage without a label until the next sync. Usingupserton 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 applyingonConflict.🤖 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
📒 Files selected for processing (12)
app/dashboard/crm/page.tsxapp/globals.cssapp/layout.tsxcomponents/AppShell.tsxcomponents/crm/Etiquetas.tsxcomponents/crm/Kanban.tsxcomponents/crm/Thread.tsxlib/crm-atividades-actions.tslib/crm-etapas.tslib/crm-etiquetas-actions.tslib/crm-kanban-actions.tssupabase/migrations/20260714_crm_fase6_etapas_reordenaveis_agendar_sync.sql
| 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() | ||
| }) | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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.
| // 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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[] |
There was a problem hiding this comment.
🎯 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.
| 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.
| // 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}".` } |
There was a problem hiding this comment.
🗄️ 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}")
PYRepository: 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 -nRepository: 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.
| 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 } | ||
| } |
There was a problem hiding this comment.
🗄️ 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.
…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
ordemna 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
Bug Fixes
Changes