Camada de CRM para assessores sobre o clone do WhatsApp - #4
Camada de CRM para assessores sobre o clone do WhatsApp#4matheusfrainer wants to merge 2 commits into
Conversation
Acrescenta ao clone quatro áreas de largura total (funil, carteiras, agenda e automações), um painel de cliente ao lado da conversa e uma leitura da conversa por LLM. Continua sem backend: o estado todo vive em localStorage. A única rota de servidor é POST /api/insights, que chama a API da Anthropic com JSON Schema no output_config. Sem ANTHROPIC_API_KEY ela responde 501 e a UI cai para a heurística local, de modo que a aba de IA nunca fica vazia. Decisões que o código sozinho não entrega: - O estado inicial é vazio por construção. A página é pré-renderizada no servidor, que não lê localStorage, então qualquer dado de domínio no estado inicial pinta primeiro e se reescreve depois. O seed passa a entrar pelo HYDRATE, no cliente; e2e/hydration.spec.ts trava esse contrato, inclusive que nome do seed nenhum apareça no HTML pré-renderizado. - Regras de automação globais vivem no store e as de contato vivem no chat. Ligar ou desligar uma global para um contato grava um override, nunca muta a regra compartilhada. - A chave de storage vai para v3: CrmData mudou de forma de maneira incompatível e não há migração, então um payload antigo cai para o seed. - O scheduler só roda com a aba aberta e apenas propõe; quem aprova é a Agenda. Não há fila do lado do servidor e a UI diz isso. 37 testes e2e em 5 specs, verdes em desktop e mobile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O README descrevia só o clone de chat e já não mencionava CRM, funil, carteiras, agenda, automações nem a análise por LLM — metade do projeto. Em vez de reescrevê-lo, o projeto passa a ter um CLAUDE.md, que é o que de fato se usa aqui. O arquivo cobre comandos, o mapa dos diretórios, as regras não óbvias (contrato de hidratação, versionamento da chave de storage, override das automações, o modo determinístico dos testes com ?e2e=1) e as convenções — sem repetir o que o código já diz. Dois fatos que só existiam no README foram preservados: a fonte Geist vem dos itens oficiais do registry, e Status e Canais ficaram intencionalmente fora do clone. Sem o segundo, a ausência deles se lê como pendência. O README da raiz perde o link para o arquivo removido. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe WhatsApp application now includes a CRM domain, automation workflows, local and Anthropic-backed insights, advisory workspaces, responsive CRM panels, expanded persistence, shared UI primitives, and Playwright coverage. ChangesWhatsApp CRM application
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Operator
participant CRMPanel
participant Store
participant AutomationEngine
participant AnthropicAPI
Operator->>CRMPanel: edit CRM data or request insights
CRMPanel->>Store: dispatch CRM or insight action
Store->>CRMPanel: return persisted state
CRMPanel->>AnthropicAPI: request structured insights
AnthropicAPI->>CRMPanel: return insight sections and suggestions
AutomationEngine->>Store: create and update pending actions
Store->>CRMPanel: expose agenda and automation state
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
whatsapp/components/whatsapp/contact-panel.tsx (1)
136-150: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winRender a thumbnail for videos in the media grid.
videomessages includeurlandduration, but no poster/thumbnail field. Since the grid maps everymediaitem and currently returnsnullfor videos, add aposter/thumbnailfield toVideoMessageand render it for video entries.🤖 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 `@whatsapp/components/whatsapp/contact-panel.tsx` around lines 136 - 150, Extend the VideoMessage model with a poster/thumbnail field, then update the media grid mapping to render video entries using that field instead of returning null. Preserve the existing image rendering and ensure both media types remain keyed by m.id.Source: Learnings
whatsapp/lib/store.tsx (1)
502-516: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winAlso drop
pendingandinsightsentries for the deleted chat.
DELETE_CHATprunescalls,communities,blocked, andselectedId. It does not prunestate.pendingorstate.insights. Both are keyed by chat id.Two dangling references remain after a delete:
- A
PendingActionwhosechatIdno longer resolves stays in the review queue. The Agenda view then lists an action for a contact that does not exist, and approving it dispatches against a missing chat.- The cached insight under the deleted id is persisted forever. If a new chat later reuses that id,
fingerprint(chat)guards the content, but the entry never expires on its own.
DELETE_AUTOMATIONat Line 587 already prunespendingfor the same reason, so the pattern is established.🐛 Proposed fix
case "DELETE_CHAT": return { ...state, chats: state.chats.filter((c) => c.id !== action.chatId), // Drop references that would otherwise dangle: a call row pointing at // a deleted chat silently does nothing when clicked. calls: state.calls.filter((c) => c.chatId !== action.chatId), communities: state.communities.map((com) => ({ ...com, groupIds: com.groupIds.filter((id) => id !== action.chatId), })), blocked: state.blocked.filter((id) => id !== action.chatId), + // An approved action for a chat that no longer exists would sit in the + // Agenda queue and dispatch against nothing when sent. + pending: state.pending.filter((p) => p.chatId !== action.chatId), + insights: Object.fromEntries( + Object.entries(state.insights).filter( + ([chatId]) => chatId !== action.chatId + ) + ), selectedId: state.selectedId === action.chatId ? null : state.selectedId, }🤖 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 `@whatsapp/lib/store.tsx` around lines 502 - 516, Update the DELETE_CHAT reducer case to remove entries from state.pending and state.insights keyed by action.chatId, alongside the existing calls, communities, blocked, and selectedId cleanup. Preserve all unrelated pending actions and insight entries, following the pruning pattern used by DELETE_AUTOMATION.
🟡 Minor comments (12)
whatsapp/components/whatsapp/contact-panel.tsx-69-70 (1)
69-70: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winTranslate the Dialog close control.
This new
DialogContentuse enables its default close button. The shared implementation exposes the accessible text"Close". Change that shared text to"Fechar".As per coding guidelines, UI strings and
aria-labelvalues must be in Brazilian Portuguese.🤖 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 `@whatsapp/components/whatsapp/contact-panel.tsx` around lines 69 - 70, Update the shared Dialog close control’s accessible text from “Close” to “Fechar”, including its aria-label, while preserving the existing close behavior and DialogContent usage.Source: Coding guidelines
whatsapp/components/ui/checkbox.tsx-18-18 (1)
18-18: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winMap Radix states to the actual data attributes.
whatsapp/app/globals.cssdefines onlydark;data-checked,data-open,data-closed,data-horizontal,data-vertical, anddata-activemap to missing/literal attributes in Tailwind v4. Define the@custom-variantshorthands or rename the class variants todata-[state=checked],data-[state=open],data-[state=closed],data-[state=active], anddata-orientation.🤖 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 `@whatsapp/components/ui/checkbox.tsx` at line 18, Map Radix state variants to their actual attributes in whatsapp/components/ui/checkbox.tsx:18-18, whatsapp/components/ui/select.tsx:83-83, whatsapp/components/ui/tabs.tsx:19-19, and whatsapp/components/ui/tabs.tsx:67-69 by defining the corresponding `@custom-variant` shorthands in whatsapp/app/globals.css or replacing the literal variants with data-[state=checked], data-[state=open], data-[state=closed], data-[state=active], and data-orientation selectors; ensure all affected Checkbox, Select, and Tabs styling targets the emitted Radix attributes.whatsapp/app/api/insights/route.ts-24-75 (1)
24-75: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winEnforce the five-section response contract.
SCHEMA.sectionspermits empty arrays, extra sections, and duplicate section IDs. The type assertion afterJSON.parse()adds no runtime validation. A schema-valid model response can therefore omit or duplicate panel sections.Set
minItemsandmaxItemsto five. Validate the expected unique IDs and order before returningdata.Also applies to: 216-223
🤖 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 `@whatsapp/app/api/insights/route.ts` around lines 24 - 75, Update SCHEMA.sections to require exactly five entries using minItems and maxItems set to 5, then add runtime validation after JSON.parse() that confirms the section IDs are exactly ["comunicacao", "decisao", "ips", "oportunidade", "acoes"] in that order and rejects invalid responses before returning data.whatsapp/hooks/use-automation-scheduler.ts-75-77 (1)
75-77: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winCorrija a concordância verbal no plural do toast.
Com
due.length > 1, o texto renderiza "2 mensagens aprovadas saiu agora". O verbo permanece no singular.✏️ Correção sugerida
- toast("Automações enviadas", { - description: `${due.length} mensagem${due.length > 1 ? "s" : ""} aprovada${due.length > 1 ? "s" : ""} saiu agora.`, - }) + const plural = due.length > 1 + toast("Automações enviadas", { + description: `${due.length} mensagem${plural ? "s" : ""} aprovada${plural ? "s" : ""} ${plural ? "saíram" : "saiu"} agora.`, + })🤖 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 `@whatsapp/hooks/use-automation-scheduler.ts` around lines 75 - 77, Atualize o toast na função que processa as automações agendadas para ajustar a concordância verbal conforme due.length: use o verbo no plural quando houver mais de uma mensagem e mantenha o singular para uma mensagem.Source: Coding guidelines
whatsapp/components/whatsapp/views/agenda-view.tsx-205-207 (1)
205-207: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winThe "Resolvidas hoje" section is not restricted to today.
doneselects everysentordeclinedaction, regardless of itsdayfield. Actions resolved on earlier days appear under a heading that states today.PendingActioncarriesday, so the filter can use it.+ const today = new Date() + const dayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}` const done = state.pending.filter( - (p) => p.status === "sent" || p.status === "declined" + (p) => (p.status === "sent" || p.status === "declined") && p.day === dayKey )Alternatively, change the heading to "Resolvidas".
🤖 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 `@whatsapp/components/whatsapp/views/agenda-view.tsx` around lines 205 - 207, Update the done filter in the agenda view to include only actions whose status is sent or declined and whose day matches today, using PendingAction.day and the view’s existing today/date value. Preserve the current resolved-actions behavior for today while excluding actions from earlier days.whatsapp/components/whatsapp/nav-rail.tsx-69-71 (1)
69-71: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winO
aria-labeldo badge de conversas anuncia "pendentes".
railButtongera o mesmo sufixo para os dois tipos de badge. Em "Conversas" o número representa mensagens não lidas, não itens pendentes. O leitor de tela anuncia "Conversas, 3 pendentes".Passe o texto do badge junto com o valor.
♿ Correção sugerida
- function railButton(entry: Entry, badge?: number) { + function railButton(entry: Entry, badge?: number, badgeNoun = "pendentes") { const active = state.view === entry.view return ( <Tooltip key={entry.view}> <TooltipTrigger asChild> <Button variant="ghost" size="icon-lg" aria-label={ - badge ? `${entry.label}, ${badge} pendentes` : entry.label + badge ? `${entry.label}, ${badge} ${badgeNoun}` : entry.label }{PRIMARY.map((entry) => - railButton(entry, entry.view === "chats" ? unreadTotal : undefined) + railButton( + entry, + entry.view === "chats" ? unreadTotal : undefined, + "não lidas" + ) )}🤖 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 `@whatsapp/components/whatsapp/nav-rail.tsx` around lines 69 - 71, Atualize o uso de `railButton` e o `aria-label` para receber também o texto do badge junto com seu valor, evitando que o sufixo fixo “pendentes” seja aplicado a Conversas. Preserve a descrição “pendentes” para badges de itens pendentes e use a descrição apropriada de mensagens não lidas para o badge de Conversas.Source: Coding guidelines
whatsapp/e2e/crm.spec.ts-126-144 (1)
126-144: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winPersist an actual CRM edit before reloading.
This test only expands the
"Cliente"section. Both assertions read unchanged seeded data. It does not validate that a CRM mutation reacheslocalStorage.Edit an input such as
"Empresa"before the reload. Then assert the changed value after reopening the panel.Proposed test change
const panel = page.getByRole("tabpanel") await panel.getByRole("button", { name: "Cliente", exact: true }).click() await expect(panel.getByText(/Cliente desde/)).toBeVisible() + const company = panel.getByRole("textbox", { name: "Empresa" }) + await company.fill("Empresa persistida") // Give the store's debounced write a chance to land before reloading. await page.waitForTimeout(600) await page.reload() await openChat(page, "Ana Beatriz") await openClientPanel(page) await expect( - page.getByRole("tabpanel").getByText(/Cliente desde/) - ).toBeVisible() + page.getByRole("tabpanel").getByRole("textbox", { name: "Empresa" }) + ).toHaveValue("Empresa persistida")🤖 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 `@whatsapp/e2e/crm.spec.ts` around lines 126 - 144, Update the test “edits CRM data and keeps it across a reload” to modify a CRM input, such as “Empresa,” after opening the “Cliente” section, then assert the edited value before and after page.reload(). Keep the existing panel reopening flow and verify the persisted changed value rather than only the unchanged “Cliente desde” text.whatsapp/e2e/crm.spec.ts-39-102 (1)
39-102: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winFreeze the clock for this deterministic ordering test.
sendMessage()readsIntl.DateTimeFormat(...).format(new Date()), so running the test at 23:59 can make the sent message have the same time as"última da noite". Clock ties are not tied to the existing ordering, sosent < nightcan fail. Set the application clock beforepage.goto()to a time between00:01and23:58.🤖 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 `@whatsapp/e2e/crm.spec.ts` around lines 39 - 102, Freeze the application clock in the ordering test before page.goto(), using the test framework’s clock control to set a time strictly between 00:01 and 23:58. Keep the existing seeded messages and ordering assertions unchanged, ensuring sendMessage() produces a deterministic time distinct from both seeded messages.Source: Coding guidelines
whatsapp/components/whatsapp/conversation.tsx-410-438 (1)
410-438: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winMake the narrow-layout branch toggle, so
aria-pressedstays truthful.The button declares
aria-pressedat Line 419, which announces it as a toggle button. On narrow layouts the handler at Line 427 only callssetCrmSheetOpen(true). Activating the button a second time does not change the state, so the announced pressed state can never be reversed by the control that announces it.The sheet does close through its own dismiss control and Escape, and
onOpenChangewrites back tocrmSheetOpen. The gap is limited to the button itself. Make the narrow branch toggle to match the wide branch.♿ Proposed fix
onClick={() => wide ? dispatch({ type: "SET_PREFERENCE", key: "crmPanel", value: !crmPanelOpen, }) - : setCrmSheetOpen(true) + : setCrmSheetOpen((open) => !open) } > <Icon icon={PipelineIcon} /> </Button> </TooltipTrigger> <TooltipContent> - {wide && crmPanelOpen + {(wide ? crmPanelOpen : crmSheetOpen) ? "Ocultar painel do cliente" : "Painel do cliente"} </TooltipContent>As per coding guidelines: "UI strings,
aria-labelvalues, and commit messages must be in Brazilian Portuguese" — the proposed tooltip text stays in Brazilian Portuguese.🤖 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 `@whatsapp/components/whatsapp/conversation.tsx` around lines 410 - 438, Update the narrow-layout branch of the Button onClick handler to toggle crmSheetOpen using its current state, while preserving the wide-layout preference toggle and existing Portuguese labels. Ensure repeated activations alternately open and close the sheet so aria-pressed remains truthful.Source: Coding guidelines
whatsapp/lib/storage.ts-26-29 (1)
26-29: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winPrune reviewed actions so
pendingdoes not grow without bound.
sanitize()keeps every action whose status is not"pending". Actions with status"approved","declined", and"sent"are therefore persisted forever. Each day adds new rows, and the array is never pruned. Two effects follow: the persisted payload grows until the localStorage quota is reached, and the dedup set built inSET_PENDINGinwhatsapp/lib/store.tsxgrows with it.Keep only a recent window of reviewed actions. The dedup key already carries
day, so a date cutoff is enough.🧹 Proposed retention window
+// Reviewed actions only matter for the dedup check, which is keyed by day. +// Anything older than a week can never suppress a fresh proposal, so drop it +// instead of letting the queue grow until the quota fails. +const RETAIN_DAYS = 7 + function sanitize(state: PersistedState): PersistedState { + const cutoff = new Date(Date.now() - RETAIN_DAYS * 86_400_000) + .toISOString() + .slice(0, 10) return { ...state, chats: state.chats.map((chat) => { const copy = { ...chat } delete copy.typing return copy }), // Approved-but-unsent actions are re-evaluated on load; a "sent" flag is // history and stays. Nothing pending survives a reload as pending, so a // rule whose trigger no longer holds doesn't fire from a stale queue. - pending: (state.pending ?? []).filter((p) => p.status !== "pending"), + pending: (state.pending ?? []).filter( + (p) => p.status !== "pending" && p.day >= cutoff + ), } }As per coding guidelines: "
sanitize()must run on both read and write".🤖 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 `@whatsapp/lib/storage.ts` around lines 26 - 29, Update sanitize() to retain only reviewed actions from a recent date window, using each action’s dedup-key day, while continuing to exclude status "pending" and preserve current handling for current-window actions. Ensure sanitize() is applied on both storage reads and writes so stale reviewed entries are pruned before persistence and before rebuilding the SET_PENDING dedup set.Source: Coding guidelines
whatsapp/components/whatsapp/crm-panel.tsx-49-68 (1)
49-68: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
SavedHintmostra "salvo" ao trocar de conversa.O componente compara a assinatura de
chat.crmentre renders. Quando o usuário troca de contato, o propchatmuda e a assinatura muda junto. O componente então marcasaved = truesem que nenhuma edição tenha ocorrido.PanelTabsé reiniciado porkey={chat.id}, masSavedHintnão.Inclua o
chat.idna comparação para reiniciar sem sinalizar salvamento.🐛 Correção proposta
function SavedHint({ chat }: { chat: Chat }) { const [saved, setSaved] = React.useState(false) const signature = JSON.stringify(chat.crm ?? {}) const [seen, setSeen] = React.useState(signature) + const [seenChat, setSeenChat] = React.useState(chat.id) // Adjusting state during render, not in an effect: React re-runs the render // before painting, where an effect would paint the stale value first and // cascade a second render on top of it. - if (seen !== signature) { + if (seenChat !== chat.id) { + // A different contact is not an edit, so reset without announcing a save. + setSeenChat(chat.id) + setSeen(signature) + setSaved(false) + } else if (seen !== signature) { setSeen(signature) setSaved(true) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@whatsapp/components/whatsapp/crm-panel.tsx` around lines 49 - 68, Update SavedHint’s render-time change detection to track both chat.id and the chat.crm signature, so switching conversations resets the tracking state without setting saved to true. Preserve the existing behavior of setting saved for CRM edits within the same chat and keep the timer logic unchanged.whatsapp/components/whatsapp/profile-tab.tsx-246-260 (1)
246-260: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winTrocar de conversa dentro da janela de 400 ms descarta as observações digitadas.
A sincronização em render redefine
notesquandochat.idmuda. A limpeza do efeito cancela o temporizador pendente do contato anterior. O texto digitado nos últimos 400 ms nunca chega ao store.Grave o rascunho pendente na limpeza, usando o
chatIdcorreto.🐛 Correção proposta
React.useEffect(() => { const handle = window.setTimeout(() => { if ((crm.notes ?? "") !== notes) set({ notes: notes || undefined }) }, 400) - return () => window.clearTimeout(handle) + return () => { + window.clearTimeout(handle) + // Sair do contato antes do debounce não pode perder o que já foi digitado. + if ((crm.notes ?? "") !== notes) { + dispatch({ + type: "SET_CRM", + chatId: chat.id, + patch: { notes: notes || undefined }, + }) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [notes, chat.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 `@whatsapp/components/whatsapp/profile-tab.tsx` around lines 246 - 260, Update the notes debounce effect around notes, chat.id, and set so its cleanup persists any pending draft before the timer is cancelled, using the chatId captured for that effect rather than the newly selected chat. Preserve the existing 400 ms debounced save while ensuring switching conversations does not discard recently typed notes.
🧹 Nitpick comments (19)
whatsapp/lib/insights.ts (2)
9-14: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMake interface comments explain constraints.
These comments restate field names and types. Remove them or explain why each field is optional or required.
As per coding guidelines, “Comments must explain why, not what, including comments on interface fields.”
🤖 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 `@whatsapp/lib/insights.ts` around lines 9 - 14, Update the interface field comments for label, value, and detail to explain their constraints or purpose rather than restating their names and types; clarify why label and value are required and why detail is optional, or remove comments that do not add this context.Source: Coding guidelines
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse the
@/*alias for project imports.
whatsapp/lib/insights.ts#L1-L1: replace./typeswith@/lib/types.whatsapp/lib/insights-local.ts#L1-L13: replace relative project imports with@/lib/...aliases.whatsapp/components/whatsapp/ai-tab.tsx#L14-L24: replace relative component imports with@/components/whatsapp/...aliases.As per coding guidelines, “Use the
@/*import alias.”🤖 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 `@whatsapp/lib/insights.ts` at line 1, Replace the relative project imports with the configured `@/`* aliases: update whatsapp/lib/insights.ts lines 1-1 to use `@/lib/types`, whatsapp/lib/insights-local.ts lines 1-13 to use `@/lib/`... paths, and whatsapp/components/whatsapp/ai-tab.tsx lines 14-24 to use `@/components/whatsapp/`... paths.Source: Coding guidelines
whatsapp/components/whatsapp/whatsapp-app.tsx (1)
88-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the redundant
!workspaceterm.
state.view === "chats"already excludes every workspace view, becauseisWorkspaceView("chats")is false. The extra term adds no condition.- const conversationOpen = - Boolean(selectedChat) && state.view === "chats" && !workspace + const conversationOpen = Boolean(selectedChat) && state.view === "chats"🤖 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 `@whatsapp/components/whatsapp/whatsapp-app.tsx` around lines 88 - 91, Remove the redundant !workspace condition from the conversationOpen expression, keeping the existing selectedChat and state.view === "chats" checks unchanged.whatsapp/components/whatsapp/automation-dialog.tsx (1)
98-99: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winRequire a keyword when the trigger is
palavra-chave.The engine returns
nullimmediately whenconfig.keywordis empty. The user can save a rule that can never fire. Extendvalidto cover this case.- const valid = draft.name.trim().length > 0 && draft.message.trim().length > 0 + const valid = + draft.name.trim().length > 0 && + draft.message.trim().length > 0 && + (draft.trigger !== "palavra-chave" || + (draft.config.keyword ?? "").trim().length > 0)🤖 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 `@whatsapp/components/whatsapp/automation-dialog.tsx` around lines 98 - 99, Update the validation expression in the automation dialog around dayLabel and valid to require a non-empty trimmed keyword when draft.trigger is `palavra-chave`, while preserving the existing name and message requirements for all triggers.whatsapp/hooks/use-automation-scheduler.ts (1)
54-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPrefer a typed message literal over the
as Messagecast.The cast disables checking of
type,status, and required fields against theMessageunion. A typed annotation keeps the contract enforced if the union changes.- const message = { + const message: Message = { type: "text", id: nextId(), fromMe: true, text: action.message, time: nowTime(now), status: "sent", - } as Message + }🤖 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 `@whatsapp/hooks/use-automation-scheduler.ts` around lines 54 - 63, Replace the `as Message` cast on the message literal inside the due-action loop with an explicit `Message` type annotation. Preserve the existing fields and dispatch behavior while ensuring the literal is checked against the `Message` union and required fields remain enforced.whatsapp/components/whatsapp/views/carteiras-view.tsx (2)
249-251: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDrop
cnaround the description string.
cnmerges class names throughclsxandtailwind-merge. Here it receives a single interpolated description and returns it unchanged. Pass the template literal directly.- description={cn( - `${formatCurrency(totals.auc)} sob gestão · ${formatCurrency(totals.pipe)} a captar` - )} + description={`${formatCurrency(totals.auc)} sob gestão · ${formatCurrency(totals.pipe)} a captar`}The
cnimport is then unused in this file.🤖 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 `@whatsapp/components/whatsapp/views/carteiras-view.tsx` around lines 249 - 251, Remove the unnecessary cn wrapper from the description prop in the relevant carteira view, passing the interpolated string directly. Then remove the now-unused cn import from the file.
208-231: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueHoist the
maxcomputation out of the map.
maxscans every lead once per stage inside the loop, so the block is O(stages² × leads). The value does not depend on the current stage. Compute the per-stage counts once, then derivemaxand render.🤖 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 `@whatsapp/components/whatsapp/views/carteiras-view.tsx` around lines 208 - 231, Refactor the stage rendering around stageOrder.map so per-stage lead counts are computed once before the map. Derive the shared max from those counts, then reuse each stage’s count and max when rendering BarRow, avoiding repeated leads.filter scans inside the loop.whatsapp/components/whatsapp/views/automacoes-view.tsx (2)
124-132: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winConfirm the deletion of a shared library rule.
DELETE_AUTOMATIONremoves a rule that applies to every contact. The menu item runs immediately, and there is no undo path in the store. Add a confirmation step, or offer an undo action in a toast.🤖 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 `@whatsapp/components/whatsapp/views/automacoes-view.tsx` around lines 124 - 132, Update the DELETE_AUTOMATION action in the DropdownMenuItem to require confirmation before removing the shared rule, or provide an equivalent undo action through a toast; preserve the existing deletion dispatch only after confirmation or when the undo flow is finalized.
150-153: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
localsincludes groups and archived chats.
RuleRowat Line 47 counts only the book, which excludes groups and archived chats. This list usesstate.chatsunfiltered, so a rule on an archived contact appears under "Só de uma pessoa". Use the same filter for both.- const locals = state.chats.flatMap((chat) => - (chat.automations ?? []).map((rule) => ({ chat, rule })) - ) + const locals = state.chats + .filter((c) => !c.isGroup && !c.archived) + .flatMap((chat) => (chat.automations ?? []).map((rule) => ({ chat, rule })))🤖 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 `@whatsapp/components/whatsapp/views/automacoes-view.tsx` around lines 150 - 153, Update the locals computation in the automações view to filter state.chats using the same criteria as RuleRow before flattening automations, excluding groups and archived chats. Keep the existing { chat, rule } mapping and ensure both the list and RuleRow count only eligible contacts.whatsapp/components/whatsapp/views/workspace-shell.tsx (1)
14-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe
tabsslot is unused; the three workspaces duplicate its markup.
FunilView,CarteirasView, andAutomacoesVieweach wrap theirTabsListin<div className="border-b border-border px-4 py-2">insidechildren, which repeats exactly what Lines 38-40 provide. Either pass the tab bar through thetabsprop in those views, or remove the prop.Note that the
Tabsroot must stay above both the tab bar and the content, so using the slot requires the shell to sit insideTabsin each view.Also applies to: 38-40
🤖 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 `@whatsapp/components/whatsapp/views/workspace-shell.tsx` around lines 14 - 15, Remove the unused tabs prop from the workspace shell and update FunilView, CarteirasView, and AutomacoesView accordingly, or pass each view’s tab bar through that prop while keeping the Tabs root above both the tab bar and content. Eliminate the duplicated border wrapper markup and preserve the existing tab layout.whatsapp/components/whatsapp/views/campanhas-view.tsx (2)
37-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated
nowTimehelper in two outbound message paths. Both files define the same pt-BRHH:MMformatter to stamp messages the application sends. The shared root cause is a missing helper in a common module, for examplelib/crm.tsnext toformatDate, orlib/data.tsnext to the other message helpers.
whatsapp/components/whatsapp/views/campanhas-view.tsx#L37-L42: remove the localnowTimeand import the shared helper; pass theDateexplicitly so the campaign stamps every message with one instant.whatsapp/hooks/use-automation-scheduler.ts#L14-L19: remove the localnowTimeand import the same shared helper.🤖 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 `@whatsapp/components/whatsapp/views/campanhas-view.tsx` around lines 37 - 42, Centralize the duplicated pt-BR time formatter in a shared module near the existing message/date helpers. In whatsapp/components/whatsapp/views/campanhas-view.tsx lines 37-42, remove the local nowTime, import the shared helper, and pass one explicit Date so all campaign messages use the same instant; in whatsapp/hooks/use-automation-scheduler.ts lines 14-19, remove the local nowTime and import the same helper.
291-298: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winConfirm the broadcast before writing into the conversations.
The button sends a message to every recipient in one click. The writes land in real conversations and there is no undo. Add a confirmation dialog that repeats the recipient count.
🤖 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 `@whatsapp/components/whatsapp/views/campanhas-view.tsx` around lines 291 - 298, Update the send action around the Button and send handler to require explicit user confirmation before broadcasting to recipients. Add a confirmation dialog that clearly repeats recipients.length, invoke send only after confirmation, and preserve the existing disabled conditions and button label.whatsapp/components/whatsapp/views/agenda-view.tsx (1)
42-48: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winThe edit draft does not follow later changes to the action.
draftis seeded once fromaction.message.Reavaliarat Line 209 replaces the pending queue, and the scheduler replaces it every 60 seconds. If the card remounts with a different message while the editor is closed, the stored draft is correct; if the same card instance receives a newaction.message, the editor still shows the old text and saving overwrites the new message.Reset the draft when the editor opens.
- const [editing, setEditing] = React.useState(false) - const [draft, setDraft] = React.useState(action.message) + const [editing, setEditing] = React.useState(false) + const [draft, setDraft] = React.useState(action.message) + // Reopening the editor must show the current message, not a stale draft. + function toggleEditing() { + setDraft(action.message) + setEditing((v) => !v) + }Then call
toggleEditingat Line 74.🤖 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 `@whatsapp/components/whatsapp/views/agenda-view.tsx` around lines 42 - 48, Update ActionCard’s editing toggle so opening the editor first resets draft from the current action.message, while closing preserves existing behavior. Ensure the edit control invokes toggleEditing rather than changing editing state directly, so each editor opening displays the latest action message.whatsapp/e2e/hydration.spec.ts (1)
53-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winWait for the persisted condition instead of a fixed delay.
A slow runner can exceed 600 ms before the storage write completes. Retry the expected key set with
expect(...).toPass().As per coding guidelines, “Make end-to-end tests deterministic.”
🤖 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 `@whatsapp/e2e/hydration.spec.ts` around lines 53 - 59, Replace the fixed waitForTimeout delay in the hydration test with an expect(...).toPass() retry around the localStorage key-set assertion after page.goto. Keep reading “whatsapp-shadcn:state:v3” via page.evaluate and assert the expected sorted keys so the test waits for persistence to complete deterministically.Source: Coding guidelines
whatsapp/components/whatsapp/crm-panel.tsx (1)
70-80: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueO texto "salvo" permanece na árvore de acessibilidade quando oculto.
opacity-0esconde o texto visualmente, mas o mantém acessível a leitores de tela. Comaria-live="polite", a região deve conter o texto apenas enquanto a mensagem for válida. Renderize o texto de forma condicional dentro da região viva.♻️ Ajuste sugerido
<span aria-live="polite" - className={cn( - "text-[0.625rem] text-muted-foreground transition-opacity", - saved ? "opacity-100" : "opacity-0" - )} - > - salvo - </span> + className="text-[0.625rem] text-muted-foreground transition-opacity" + > + {saved ? "salvo" : null} + </span>🤖 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 `@whatsapp/components/whatsapp/crm-panel.tsx` around lines 70 - 80, Atualize o elemento com aria-live no fluxo de renderização do painel para renderizar o texto “salvo” somente quando saved for verdadeiro, em vez de mantê-lo na árvore com opacity-0. Preserve a região viva e os estilos existentes para o estado em que a mensagem é exibida.whatsapp/components/whatsapp/currency-input.tsx (1)
44-49: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueO rascunho inicial usa ponto decimal.
String(value)produz "1250.5" enquanto o campo aceita e exibe vírgula decimal. O usuário vê um formato ao focar e outro ao ler. Formate o rascunho com vírgula para manter o padrão pt-BR.♻️ Ajuste sugerido
onFocus={() => { - setDraft(value != null ? String(value) : "") + setDraft(value != null ? String(value).replace(".", ",") : "") setEditing(true) }}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@whatsapp/components/whatsapp/currency-input.tsx` around lines 44 - 49, Atualize o callback onFocus do componente de entrada para inicializar draft usando a formatação pt-BR já aplicada à exibição, substituindo o String(value) bruto por uma conversão que use vírgula como separador decimal. Preserve o comportamento para valores nulos e o fluxo existente de setEditing.whatsapp/components/whatsapp/profile-tab.tsx (1)
594-608: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winO resumo da reunião grava no store a cada tecla.
O comentário na linha 246 declara que digitar não deve reescrever o chat a cada caractere. Este
Textareafaz exatamente isso: cadaonChangereconstrói o arraymeetingse despachaSET_CRM, o que também persiste emlocalStoragea cada tecla. O título da mesma reunião usaField, que grava no blur. Aplique o mesmo padrão ao resumo, com estado local e commit no blur ou com debounce.🤖 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 `@whatsapp/components/whatsapp/profile-tab.tsx` around lines 594 - 608, Atualize o Textarea de resumo da reunião no componente correspondente para usar estado local durante a digitação e persistir a alteração apenas no blur, seguindo o padrão do Field usado para o título; evite reconstruir meetings e despachar SET_CRM a cada onChange, mantendo o valor final sincronizado com meeting.summary.whatsapp/lib/crm.ts (2)
177-187: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCorrija o comentário de
daysUntilBirthday.A função nunca retorna valores negativos. Quando a data já passou no ano corrente, ela avança para o próximo ano. O comentário diz "Days since the birthday", o que não descreve o retorno.
♻️ Ajuste sugerido
-/** Days since the birthday, or until the next one. */ +/** Days until the next birthday; never negative. */🤖 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 `@whatsapp/lib/crm.ts` around lines 177 - 187, Atualize o comentário de `daysUntilBirthday` para descrever que a função retorna os dias até o próximo aniversário, sem mencionar dias desde o aniversário. Não altere a lógica da função.
87-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
crmOfdevolve o mesmo objetoemptyCrmpara todo chat sem CRM.Os arrays
tags,positionsemeetingssão compartilhados entre todos os contatos sem dados. Hoje os consumidores usam apenas leitura e spread, então não há defeito. Uma mutação futura em qualquer tela afetaria todos os contatos. Retornar uma cópia elimina esse risco.🛡️ Correção defensiva sugerida
export function crmOf(chat: Chat): CrmData { const crm = chat.crm - if (!crm) return emptyCrm + if (!crm) { + return { ...emptyCrm, tags: [], positions: [], meetings: [], profile: { ...emptyProfile } } + }🤖 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 `@whatsapp/lib/crm.ts` around lines 87 - 115, Update crmOf so chats without CRM return a fresh CrmData object instead of the shared emptyCrm reference, including newly allocated tags, positions, and meetings arrays and an independent profile object. Preserve the existing emptyCrm values while preventing mutations from being shared across contacts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d76785db-6641-456d-b182-8b02f3c64415
⛔ Files ignored due to path filters (1)
whatsapp/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (53)
README.mdwhatsapp/.gitignorewhatsapp/AGENTS.mdwhatsapp/CLAUDE.mdwhatsapp/README.mdwhatsapp/app/api/insights/route.tswhatsapp/components/ui/bubble.tsxwhatsapp/components/ui/checkbox.tsxwhatsapp/components/ui/label.tsxwhatsapp/components/ui/message-scroller.tsxwhatsapp/components/ui/progress.tsxwhatsapp/components/ui/select.tsxwhatsapp/components/ui/table.tsxwhatsapp/components/ui/tabs.tsxwhatsapp/components/whatsapp/ai-tab.tsxwhatsapp/components/whatsapp/automation-dialog.tsxwhatsapp/components/whatsapp/automations-tab.tsxwhatsapp/components/whatsapp/chat-composer.tsxwhatsapp/components/whatsapp/chat-list.tsxwhatsapp/components/whatsapp/contact-panel.tsxwhatsapp/components/whatsapp/conversation.tsxwhatsapp/components/whatsapp/crm-panel.tsxwhatsapp/components/whatsapp/currency-input.tsxwhatsapp/components/whatsapp/icons.tswhatsapp/components/whatsapp/message-bubble.tsxwhatsapp/components/whatsapp/nav-rail.tsxwhatsapp/components/whatsapp/panel-section.tsxwhatsapp/components/whatsapp/portfolio-tab.tsxwhatsapp/components/whatsapp/profile-tab.tsxwhatsapp/components/whatsapp/tag-combobox.tsxwhatsapp/components/whatsapp/views/agenda-view.tsxwhatsapp/components/whatsapp/views/automacoes-view.tsxwhatsapp/components/whatsapp/views/campanhas-view.tsxwhatsapp/components/whatsapp/views/carteiras-view.tsxwhatsapp/components/whatsapp/views/funil-view.tsxwhatsapp/components/whatsapp/views/workspace-shell.tsxwhatsapp/components/whatsapp/whatsapp-app.tsxwhatsapp/e2e/app.spec.tswhatsapp/e2e/crm.spec.tswhatsapp/e2e/hydration.spec.tswhatsapp/e2e/workspaces.spec.tswhatsapp/hooks/use-automation-scheduler.tswhatsapp/hooks/use-media-query.tswhatsapp/lib/automation-engine.tswhatsapp/lib/crm.tswhatsapp/lib/data.tswhatsapp/lib/insights-local.tswhatsapp/lib/insights.tswhatsapp/lib/portfolio.tswhatsapp/lib/storage.tswhatsapp/lib/store.tsxwhatsapp/lib/types.tswhatsapp/package.json
💤 Files with no reviewable changes (1)
- whatsapp/README.md
| let chat: Chat | ||
| try { | ||
| const body = (await request.json()) as { chat?: Chat } | ||
| if (!body.chat?.id) throw new Error("chat ausente") | ||
| chat = body.chat |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Protect the model endpoint from direct cost abuse.
The route has no caller authentication, authorization, rate limit, or transcript-size limit. Any client that can reach the deployment can submit a valid Chat payload and consume Anthropic capacity, including up to 8,000 output tokens per request.
Require an authorized caller and apply per-caller rate, budget, and request-size limits before client.messages.create().
🤖 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 `@whatsapp/app/api/insights/route.ts` around lines 168 - 172, Harden the
insights route before its Anthropic call: require and validate an authorized
caller, enforce per-caller rate and budget limits, and reject oversized chat
transcripts or requests before reaching client.messages.create(). Apply these
checks to the request flow around the chat parsing and preserve the existing
behavior for authorized, within-limit requests.
| <Input | ||
| id="rule-days" | ||
| type="number" | ||
| min={1} | ||
| value={draft.config.days ?? ""} | ||
| onChange={(e) => | ||
| setDraft({ | ||
| ...draft, | ||
| config: { ...draft.config, days: Number(e.target.value) }, | ||
| }) | ||
| } | ||
| className="h-8 text-xs tabular-nums" | ||
| /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Clearing the day field stores NaN and disables the rule silently.
Number("") returns NaN. The engine reads rule.config.days ?? 30, and ?? does not replace NaN. Every comparison against NaN is false, so the saved rule never fires and the user gets no feedback.
Store undefined when the field is empty, so the engine default applies.
🐛 Proposed fix
onChange={(e) =>
setDraft({
...draft,
- config: { ...draft.config, days: Number(e.target.value) },
+ config: {
+ ...draft.config,
+ days: e.target.value === ""
+ ? undefined
+ : Number(e.target.value),
+ },
})
}📝 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.
| <Input | |
| id="rule-days" | |
| type="number" | |
| min={1} | |
| value={draft.config.days ?? ""} | |
| onChange={(e) => | |
| setDraft({ | |
| ...draft, | |
| config: { ...draft.config, days: Number(e.target.value) }, | |
| }) | |
| } | |
| className="h-8 text-xs tabular-nums" | |
| /> | |
| <Input | |
| id="rule-days" | |
| type="number" | |
| min={1} | |
| value={draft.config.days ?? ""} | |
| onChange={(e) => | |
| setDraft({ | |
| ...draft, | |
| config: { | |
| ...draft.config, | |
| days: | |
| e.target.value === "" | |
| ? undefined | |
| : Number(e.target.value), | |
| }, | |
| }) | |
| } | |
| className="h-8 text-xs tabular-nums" | |
| /> |
🤖 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 `@whatsapp/components/whatsapp/automation-dialog.tsx` around lines 195 - 207,
Update the days input handler in the automation dialog to store undefined when
the field is empty instead of converting an empty string to NaN; otherwise
continue storing the numeric value so the engine’s existing rule.config.days ??
30 default applies.
| function commit() { | ||
| setEditing(false) | ||
| const cleaned = draft.replace(/[^\d,.-]/g, "").replace(",", ".") | ||
| const parsed = Number(cleaned) | ||
| if (!cleaned) return onCommit(undefined) | ||
| if (Number.isFinite(parsed)) onCommit(parsed) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
O parser rejeita valores com separador de milhar pt-BR.
O usuário digita "1.250,00". O replace mantém os pontos e troca apenas a primeira vírgula, produzindo "1.250.00". Number("1.250.00") é NaN, então onCommit não é chamado e a edição é descartada sem aviso. O mesmo ocorre com "12.500".
Remova os separadores de milhar antes de converter a vírgula decimal.
🐛 Correção proposta
function commit() {
setEditing(false)
- const cleaned = draft.replace(/[^\d,.-]/g, "").replace(",", ".")
- const parsed = Number(cleaned)
- if (!cleaned) return onCommit(undefined)
- if (Number.isFinite(parsed)) onCommit(parsed)
+ const raw = draft.replace(/[^\d,.-]/g, "")
+ if (!raw) return onCommit(undefined)
+ // pt-BR: "." separa milhar e "," separa decimal, então o ponto sai antes
+ // de a vírgula virar ponto decimal.
+ const cleaned = raw.includes(",")
+ ? raw.replaceAll(".", "").replace(",", ".")
+ : raw
+ const parsed = Number(cleaned)
+ if (Number.isFinite(parsed)) onCommit(parsed)
}🤖 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 `@whatsapp/components/whatsapp/currency-input.tsx` around lines 32 - 38,
Atualize a função commit para remover os separadores de milhar ponto antes de
converter a vírgula decimal e chamar Number. Preserve o tratamento de entrada
vazia e o commit apenas para valores finitos, garantindo que formatos como
“1.250,00” e “12.500” sejam convertidos corretamente.
| onKeyDown={(e) => { | ||
| if (e.key === "Enter") e.currentTarget.blur() | ||
| if (e.key === "Escape") { | ||
| setEditing(false) | ||
| e.currentTarget.blur() | ||
| } | ||
| }} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Escape grava o valor em vez de cancelar.
setEditing(false) não impede o commit. A chamada a blur() dispara onBlur={commit}, e commit ainda lê o draft editado. O valor digitado é gravado, ao contrário do cancelamento documentado no resumo do componente.
Use uma referência de cancelamento consultada por commit.
🐛 Correção proposta
const [editing, setEditing] = React.useState(false)
const [draft, setDraft] = React.useState("")
+ // Escape precisa vencer o blur, que dispara logo depois e leria o rascunho.
+ const cancelled = React.useRef(false)
const display = value != null ? formatCurrency(value) : ""
function commit() {
setEditing(false)
+ if (cancelled.current) {
+ cancelled.current = false
+ return
+ } if (e.key === "Escape") {
+ cancelled.current = true
setEditing(false)
e.currentTarget.blur()
}🤖 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 `@whatsapp/components/whatsapp/currency-input.tsx` around lines 52 - 58, Update
the Escape handling in the currency input’s onKeyDown and the commit function
used by onBlur so cancellation is tracked through a ref consulted by commit.
Mark the edit as canceled before blurring, have commit return without saving
when that ref is set, and preserve normal Enter/onBlur commits.
| const [draft, setDraft] = React.useState<Position>(EMPTY) | ||
| const [seen, setSeen] = React.useState<Position | null>(null) | ||
| if (open && seen !== position) { | ||
| setSeen(position) | ||
| setDraft(position ?? { ...EMPTY, id: nextId() }) | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win
A primeira "Nova posição" é salva com id vazio.
seen inicia como null e position também é null ao adicionar. A condição seen !== position é falsa, então draft permanece igual a EMPTY, cujo id é "". nextId() não é chamado.
Consequências na linha 369: a primeira posição é adicionada com id: ""; uma segunda adição nas mesmas condições encontra exists === true e sobrescreve a primeira. As chaves de lista em key={position.id} também colidem.
Use um sentinela distinto de null para o estado seen.
🐛 Correção proposta
const [draft, setDraft] = React.useState<Position>(EMPTY)
- const [seen, setSeen] = React.useState<Position | null>(null)
- if (open && seen !== position) {
+ // `undefined` marca "nunca inicializado", já que `null` é um valor válido
+ // de `position` (nova posição).
+ const [seen, setSeen] = React.useState<Position | null | undefined>(undefined)
+ if (open && seen !== position) {
setSeen(position)
setDraft(position ?? { ...EMPTY, id: nextId() })
}
+ if (!open && seen !== undefined) setSeen(undefined)🤖 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 `@whatsapp/components/whatsapp/portfolio-tab.tsx` around lines 64 - 69, Use a
sentinel distinct from null for the initial seen state in the component’s seen
state declaration, so the open && seen !== position initialization block runs
when both seen and position are null. Preserve the existing draft initialization
and ensure the first new position receives nextId() instead of EMPTY.id.
| // Funding this month = what became "cliente" in the current month, valued | ||
| // at the assets that came under management with them. | ||
| const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) | ||
| .toISOString() | ||
| .slice(0, 10) | ||
| const captured = chats | ||
| .filter((c) => { | ||
| const crm = crmOf(c) | ||
| return ( | ||
| crm.relationship === "cliente" && | ||
| crm.clientSince != null && | ||
| crm.clientSince >= monthStart | ||
| ) | ||
| }) | ||
| .reduce((n, c) => n + totalsOf(c).auc, 0) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
monthStart shifts to the previous month in negative UTC offsets.
new Date(now.getFullYear(), now.getMonth(), 1) builds local midnight. toISOString() converts to UTC before slice(0, 10). In Brazil (UTC-3), the first of the month becomes the last day of the previous month, for example 2026-07-31 instead of 2026-08-01. Clients whose clientSince is the last day of the previous month are then counted in this month's captured total.
Build the key from the local date parts.
🐛 Proposed fix
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
- .toISOString()
- .slice(0, 10)
+ // Local parts, not toISOString(): the UTC conversion moves the first of the
+ // month to the previous day in negative offsets such as UTC-3.
+ const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`📝 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.
| // Funding this month = what became "cliente" in the current month, valued | |
| // at the assets that came under management with them. | |
| const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) | |
| .toISOString() | |
| .slice(0, 10) | |
| const captured = chats | |
| .filter((c) => { | |
| const crm = crmOf(c) | |
| return ( | |
| crm.relationship === "cliente" && | |
| crm.clientSince != null && | |
| crm.clientSince >= monthStart | |
| ) | |
| }) | |
| .reduce((n, c) => n + totalsOf(c).auc, 0) | |
| // Funding this month = what became "cliente" in the current month, valued | |
| // at the assets that came under management with them. | |
| // Local parts, not toISOString(): the UTC conversion moves the first of the | |
| // month to the previous day in negative offsets such as UTC-3. | |
| const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01` | |
| const captured = chats | |
| .filter((c) => { | |
| const crm = crmOf(c) | |
| return ( | |
| crm.relationship === "cliente" && | |
| crm.clientSince != null && | |
| crm.clientSince >= monthStart | |
| ) | |
| }) | |
| .reduce((n, c) => n + totalsOf(c).auc, 0) |
🤖 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 `@whatsapp/components/whatsapp/views/carteiras-view.tsx` around lines 147 -
161, Update monthStart in the funding calculation to derive the YYYY-MM-DD key
directly from the local year, month, and day parts instead of converting local
midnight through toISOString(). Preserve the existing captured filter and
totalsOf(c).auc aggregation.
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| className="h-8 text-xs" | ||
| onClick={() => { | ||
| dispatch({ type: "SET_AUDIENCE", chatIds: picked }) | ||
| dispatch({ type: "SET_VIEW", view: "automacoes" }) | ||
| onDone() | ||
| }} | ||
| > | ||
| <Icon icon={CampaignIcon} /> | ||
| Criar campanha | ||
| </Button> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
"Criar campanha" opens the Regras tab, not Campanhas.
The handler sets the audience and dispatches SET_VIEW to automacoes. AutomacoesView renders Tabs with defaultValue="regras", so the user arrives on the rules list and must find the Campanhas tab. The selected audience is not visible on that tab.
Make the target tab controllable, for example by deriving the initial tab from state.campaignAudience in AutomacoesView.
🤖 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 `@whatsapp/components/whatsapp/views/funil-view.tsx` around lines 314 - 326,
Update the navigation triggered by the “Criar campanha” button and the
corresponding AutomacoesView tab state so it opens the Campanhas tab directly
after setting the audience. Make the tab selection controllable or derive its
initial value from state.campaignAudience, while preserving the existing Regras
default when no campaign audience is selected.
| {picked.length ? ( | ||
| <BulkBar picked={picked} chats={chats} onDone={() => setPicked([])} /> | ||
| ) : null} | ||
|
|
||
| <div className="overflow-x-auto rounded-lg border border-border"> | ||
| <Table> | ||
| <TableHeader> | ||
| <TableRow> | ||
| <TableHead className="w-9"> | ||
| <Checkbox | ||
| aria-label="Selecionar todos os visíveis" | ||
| checked={ | ||
| rows.length > 0 && picked.length === rows.length | ||
| ? true | ||
| : picked.length > 0 | ||
| ? "indeterminate" | ||
| : false | ||
| } | ||
| onCheckedChange={(value) => | ||
| setPicked(value === true ? rows.map((r) => r.chat.id) : []) | ||
| } | ||
| /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
The selection survives filter changes and then acts on hidden contacts.
picked holds chat ids and is never reconciled with rows. Two consequences follow:
- The user selects rows, then changes
query,rel, orexpiring.BulkBarstill acts on the ids that are no longer visible, becauseapplyTagandmoveStageresolve ids againstchats, notrows. - The header checkbox reads
picked.length === rows.length. After a filter change, that comparison can report "all selected" while none of the visible rows are selected.
Reconcile the selection against the visible rows.
🐛 Proposed fix
+ // A selection only means something for rows the user can still see.
+ const visibleIds = React.useMemo(
+ () => new Set(rows.map((r) => r.chat.id)),
+ [rows]
+ )
+ const selected = React.useMemo(
+ () => picked.filter((id) => visibleIds.has(id)),
+ [picked, visibleIds]
+ )Then use selected for BulkBar, for the header checkbox state, and for the per-row checked value.
🤖 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 `@whatsapp/components/whatsapp/views/funil-view.tsx` around lines 438 - 459,
Reconcile the picked chat IDs with the currently visible rows by deriving a
selected collection from rows and picked. Use selected for BulkBar, the header
checkbox checked state and toggle behavior, and each row’s checked state, while
preserving picked only as the underlying selection state.
| case "sem-resposta": { | ||
| const limit = rule.config.days ?? 3 | ||
| if (!last?.fromMe) return null | ||
| const idle = daysIdle(chat, now) | ||
| return idle >= limit ? `Sua mensagem sem resposta há ${idle} dias` : null | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Guard sem-resposta against an infinite idle count.
daysIdle returns Number.POSITIVE_INFINITY for chats without updatedAt, which is the documented state of seeded chats. The sem-contato branch filters that value with Number.isFinite, but sem-resposta does not. The rule then fires with the reason text Sua mensagem sem resposta há Infinity dias, which reaches the Agenda card and the automation event log.
Apply the same finiteness check as sem-contato.
🐛 Proposed fix
case "sem-resposta": {
const limit = rule.config.days ?? 3
if (!last?.fromMe) return null
const idle = daysIdle(chat, now)
- return idle >= limit ? `Sua mensagem sem resposta há ${idle} dias` : null
+ return Number.isFinite(idle) && idle >= limit
+ ? `Sua mensagem sem resposta há ${idle} dias`
+ : null
}📝 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.
| case "sem-resposta": { | |
| const limit = rule.config.days ?? 3 | |
| if (!last?.fromMe) return null | |
| const idle = daysIdle(chat, now) | |
| return idle >= limit ? `Sua mensagem sem resposta há ${idle} dias` : null | |
| } | |
| case "sem-resposta": { | |
| const limit = rule.config.days ?? 3 | |
| if (!last?.fromMe) return null | |
| const idle = daysIdle(chat, now) | |
| return Number.isFinite(idle) && idle >= limit | |
| ? `Sua mensagem sem resposta há ${idle} dias` | |
| : null | |
| } |
🤖 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 `@whatsapp/lib/automation-engine.ts` around lines 78 - 83, Update the
"sem-resposta" branch in the rule handler to require Number.isFinite(idle)
before firing, matching the existing "sem-contato" guard. Preserve the current
last?.fromMe check, threshold comparison, and null result for non-qualifying
chats.
| export function fingerprint(chat: Chat): string { | ||
| const parts = [ | ||
| chat.id, | ||
| String(chat.conversation.reduce((n, d) => n + d.messages.length, 0)), | ||
| chat.updatedAt ? String(chat.updatedAt) : "", | ||
| JSON.stringify(chat.crm ?? {}), | ||
| ].join("|") |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Include all analysis inputs in the fingerprint.
The hash does not include message content, message timestamps, or the current analysis date. A cached model result remains fresh after a new day even though buildPrompt() includes a new “Hoje” value and time-sensitive actions change.
Hash a canonical transcript representation and the normalized analysis date used by the API route.
🤖 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 `@whatsapp/lib/insights.ts` around lines 45 - 51, Update fingerprint so it
includes every input consumed by analysis: hash a canonical representation of
the conversation messages, including their content and timestamps, and include
the normalized analysis date used by the API route. Preserve the existing chat
identity, update time, and CRM inputs while ensuring equivalent canonical data
produces stable fingerprints.
Acrescenta ao clone do WhatsApp uma camada de CRM para assessores de
investimento: quatro áreas de largura total (funil, carteiras, agenda e
automações), um painel de cliente ao lado da conversa e uma leitura da conversa
por LLM. Substitui também o README do projeto por um
CLAUDE.md.O que muda
ocupam a largura toda porque um kanban e uma tabela precisam dela.
xl, sheet nos viewportsmenores; nunca os dois no DOM ao mesmo tempo. Cadastro, perfil de investidor,
posições, automações do contato e a aba de IA.
POST /api/insightschama a API da Anthropic com JSONSchema no
output_confige o prompt de sistema atrás de um breakpoint decache.
Como continua funcionando sem chave
Não há backend: o estado todo vive em
localStorage. A rota de insights é oúnico código de servidor, e sem
ANTHROPIC_API_KEYela responde 501 — a UI caipara a heurística local (
lib/insights-local.ts), então a aba de IA nunca ficavazia. O resultado do modelo é cacheado por
fingerprint(chat), de modo quealternar de conversa e voltar não gasta requisição.
Decisões que valem a revisão
e não lê
localStorage; qualquer dado de domínio no estado inicial pintaprimeiro e se reescreve depois. O seed passa a entrar pelo
HYDRATE, nocliente.
e2e/hydration.spec.tstrava o contrato — inclusive que nome do seednenhum apareça no HTML pré-renderizado.
contato grava um override em
chat.automationOverrides; a regra compartilhadanunca é mutada.
v3.CrmDatamudou de forma de maneira incompatívele não há migração: payload antigo cai para o seed.
Agenda. Não existe fila no servidor, e a UI evita sugerir que exista.
Verificação
npm run typecheckenpm run lintlimpos; 74 testes e2e verdes (37 specs× desktop e mobile), rodando contra o build de produção.
Nota para quem for rodar
prettier --checkno Windows: comcore.autocrlf=trueele reprova o repositório inteiro, porque o
.prettierrcfixaendOfLine: "lf"e a árvore de trabalho é CRLF. É falso positivo — está documentado no
CLAUDE.md.🤖 Generated with Claude Code
Summary by CodeRabbit