feat(crm): scroll do calendário, destaque de data, e agendar unificad…#59
Conversation
…o na etiqueta Corrige o calendário cortando meses (flex-wrap quebrava o overflow:auto interno) e destaca dia/mês/semana em selo nos cartões de compromisso. Remove o botão de link do calendário (uso desnecessário por ora). Unifica "+ Agendar" dentro do picker de etiqueta: marcar uma etapa do funil que implica compromisso (reunião/follow-up/fechamento) abre o mesmo formulário de data, com o detalhe pré-preenchido "Etapa com Fulano" (editável). Etapas sem compromisso associado só trocam a fase na hora. Novo estilo do menu (maior, com emoji por etapa), botão movido pro cabeçalho da conversa no lugar do antigo "+ Agendar".
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughO PR transforma o seletor de etiquetas em um fluxo de etapas com agendamento, integra-o ao cabeçalho do Thread e adiciona helpers de atividade. Também remove links do calendário e reformula os cartões de próximos compromissos. ChangesFluxo de etapas e agendamento
Interface do calendário
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Contato
participant EtiquetasPicker
participant MenuEtapas
participant FormAgendar
participant crmActions
Contato->>EtiquetasPicker: seleciona uma etapa
EtiquetasPicker->>MenuEtapas: exibe etapas e tipos customizados
MenuEtapas-->>EtiquetasPicker: retorna a etapa escolhida
EtiquetasPicker->>FormAgendar: abre formulário quando necessário
FormAgendar->>crmActions: cria follow-up
crmActions-->>FormAgendar: retorna sucesso ou erro
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 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: 2
🧹 Nitpick comments (1)
components/crm/Calendario.tsx (1)
213-222: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winRemoving
flexWrapfixes scrolling but drops the narrow-viewport fallback.The
minHeight:0fix for month-cutoff/scroll is correct. However, droppingflexWrapalso means the calendar column (flex:"1 1 440px",minWidth:0) and the panel (flex:"1 1 300px",minWidth:264) now stay side-by-side on one row at any width instead of stacking — on narrow viewports they'll squeeze together instead of wrapping vertically as before.If this view is ever rendered below ~750px wide (tablet/narrow window), consider a media query or a width-based conditional to switch
flexDirectiontocolumnthere, since the fixed panelminWidth:264will otherwise force the calendar column below its usable width.🤖 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/Calendario.tsx` around lines 213 - 222, Preserve the parent container’s fixed-height scrolling behavior while restoring the narrow-viewport layout fallback. Update the calendar/panel wrapper around the visible flex container to switch flexDirection to column below roughly 750px, using the project’s existing responsive/media-query pattern if available; keep row layout and the current flex/minHeight settings for wider viewports.
🤖 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/Calendario.tsx`:
- Around line 477-554: Update CartaoCompromisso to use a single explicit
timezone, such as America/Sao_Paulo, for hora and diaSemana formatting, and
derive the displayed day with the same timezone-aware approach instead of
quando.getDate(). Keep the existing pt-BR formatting and card layout unchanged,
ensuring SSR and hydration produce identical values.
In `@components/crm/Etiquetas.tsx`:
- Around line 388-406: Update salvar to guard against re-entry while
criarFollowUpAction is in flight: return immediately when the existing pending
state indicates a submission is active, and set that state before awaiting the
action. Clear pending after the request completes, preserving the current
success and error handling so duplicate follow-ups cannot be created by repeated
clicks.
---
Nitpick comments:
In `@components/crm/Calendario.tsx`:
- Around line 213-222: Preserve the parent container’s fixed-height scrolling
behavior while restoring the narrow-viewport layout fallback. Update the
calendar/panel wrapper around the visible flex container to switch flexDirection
to column below roughly 750px, using the project’s existing
responsive/media-query pattern if available; keep row layout and the current
flex/minHeight settings for wider viewports.
🪄 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: 66340aac-8ff8-418e-b4ee-502867c445af
📒 Files selected for processing (4)
components/crm/Calendario.tsxcomponents/crm/Etiquetas.tsxcomponents/crm/Thread.tsxlib/crm-tipos-atividade.ts
| /** Cartão clicável de um compromisso (na lista do período e nos próximos) — | ||
| * a data vem em destaque num "selo" à esquerda (dia grande + mês/semana), | ||
| * com o nome/objetivo do compromisso ao lado. */ | ||
| function CartaoCompromisso({ | ||
| atividade, | ||
| onAbrir, | ||
| mostrarData, | ||
| }: { | ||
| atividade: CrmAtividadeRow | ||
| onAbrir: () => void | ||
| mostrarData?: boolean | ||
| }) { | ||
| const nome = atividade.lead_nome || atividade.lead_telefone || "Lead sem nome" | ||
| const quando = atividade.agendado_para ? new Date(atividade.agendado_para) : null | ||
| const hora = quando | ||
| ? quando.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }) | ||
| : "" | ||
| const dataCurta = quando | ||
| ? quando.toLocaleDateString("pt-BR", { day: "2-digit", month: "short" }) | ||
| const diaSemana = quando | ||
| ? quando.toLocaleDateString("pt-BR", { weekday: "short" }).replace(".", "") | ||
| : "" | ||
|
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={onAbrir} | ||
| className="w-full" | ||
| className="w-full flex items-stretch" | ||
| style={{ | ||
| textAlign: "left", | ||
| display: "block", | ||
| padding: 10, | ||
| borderRadius: 9, | ||
| gap: 10, | ||
| padding: 8, | ||
| borderRadius: 10, | ||
| background: "var(--surface-2, rgba(255,255,255,0.04))", | ||
| border: `1px solid ${ACCENT}2b`, | ||
| borderLeft: `3px solid ${ACCENT}`, | ||
| opacity: atividade.concluido_em ? 0.55 : 1, | ||
| }} | ||
| > | ||
| <div className="flex items-center justify-between gap-2"> | ||
| <p style={{ fontSize: 12, fontWeight: 600 }} className="truncate"> | ||
| {atividade.categoria || atividade.titulo || "Follow-up"} | ||
| </p> | ||
| <span style={{ fontSize: 10, color: ACCENT, flexShrink: 0 }}> | ||
| {mostrarData ? `${dataCurta} · ${hora}` : hora} | ||
| <div | ||
| style={{ | ||
| flexShrink: 0, | ||
| width: 44, | ||
| display: "flex", | ||
| flexDirection: "column", | ||
| alignItems: "center", | ||
| justifyContent: "center", | ||
| borderRadius: 8, | ||
| background: `${ACCENT}22`, | ||
| border: `1px solid ${ACCENT}55`, | ||
| color: ACCENT, | ||
| padding: "4px 2px", | ||
| }} | ||
| > | ||
| <span style={{ fontSize: 9, textTransform: "uppercase", fontWeight: 700, lineHeight: 1 }}> | ||
| {diaSemana} | ||
| </span> | ||
| <span style={{ fontSize: 19, fontWeight: 800, lineHeight: 1.3 }}> | ||
| {quando ? String(quando.getDate()).padStart(2, "0") : "--"} | ||
| </span> | ||
| <span style={{ fontSize: 9, fontWeight: 700, lineHeight: 1 }}> | ||
| {quando ? MESES_CURTO[quando.getMonth()] : ""} | ||
| </span> | ||
| </div> | ||
| <p style={{ fontSize: 11, color: "var(--text-3)", marginTop: 2 }} className="truncate"> | ||
| {nome} | ||
| {atividade.lead_empresa_nome ? ` · ${atividade.lead_empresa_nome}` : ""} | ||
| </p> | ||
| {atividade.concluido_em && ( | ||
| <span style={{ fontSize: 9, color: "var(--success)" }}>✓ concluído</span> | ||
| )} | ||
| <div style={{ flex: 1, minWidth: 0 }}> | ||
| <div className="flex items-center justify-between gap-2"> | ||
| <p style={{ fontSize: 13, fontWeight: 700 }} className="truncate"> | ||
| {atividade.categoria || atividade.titulo || "Follow-up"} | ||
| </p> | ||
| {hora && ( | ||
| <span style={{ fontSize: 11, color: ACCENT, flexShrink: 0, fontWeight: 600 }}> | ||
| {hora} | ||
| </span> | ||
| )} | ||
| </div> | ||
| <p style={{ fontSize: 11.5, color: "var(--text-2, #ddd)", marginTop: 2 }} className="truncate"> | ||
| {nome} | ||
| {atividade.lead_empresa_nome ? ` · ${atividade.lead_empresa_nome}` : ""} | ||
| </p> | ||
| {atividade.concluido_em && ( | ||
| <span style={{ fontSize: 9, color: "var(--success)" }}>✓ concluído</span> | ||
| )} | ||
| </div> |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Badge/card refactor looks solid; two toLocaleDateString/toLocaleTimeString calls risk hydration mismatch.
The new left "selo" badge design (weekday/day/month) and right content layout are clean and match the PR goal of surfacing day/month/week on the cards. One concern flagged by static analysis: quando.toLocaleTimeString("pt-BR", ...) (line 490) and quando.toLocaleDateString("pt-BR", { weekday: "short" }) (line 493) run during SSR and hydration without an explicit timeZone. Locale is pinned to pt-BR, but the server's timezone (commonly UTC on most hosts) can still differ from the browser's, producing a different hour/weekday string (and even a different quando.getDate() at line 530, which uses the same implicit local timezone) between server-render and hydration.
Passing an explicit timeZone (e.g. "America/Sao_Paulo") to these calls would make server and client output deterministic and avoid the hydration warning/re-render.
🕒 Proposed fix
const hora = quando
- ? quando.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })
+ ? quando.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit", timeZone: "America/Sao_Paulo" })
: ""
const diaSemana = quando
- ? quando.toLocaleDateString("pt-BR", { weekday: "short" }).replace(".", "")
+ ? quando.toLocaleDateString("pt-BR", { weekday: "short", timeZone: "America/Sao_Paulo" }).replace(".", "")
: ""As per static analysis hints, no-locale-format-in-render flags exactly this pattern on lines 490 and 493.
📝 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.
| /** Cartão clicável de um compromisso (na lista do período e nos próximos) — | |
| * a data vem em destaque num "selo" à esquerda (dia grande + mês/semana), | |
| * com o nome/objetivo do compromisso ao lado. */ | |
| function CartaoCompromisso({ | |
| atividade, | |
| onAbrir, | |
| mostrarData, | |
| }: { | |
| atividade: CrmAtividadeRow | |
| onAbrir: () => void | |
| mostrarData?: boolean | |
| }) { | |
| const nome = atividade.lead_nome || atividade.lead_telefone || "Lead sem nome" | |
| const quando = atividade.agendado_para ? new Date(atividade.agendado_para) : null | |
| const hora = quando | |
| ? quando.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }) | |
| : "" | |
| const dataCurta = quando | |
| ? quando.toLocaleDateString("pt-BR", { day: "2-digit", month: "short" }) | |
| const diaSemana = quando | |
| ? quando.toLocaleDateString("pt-BR", { weekday: "short" }).replace(".", "") | |
| : "" | |
| return ( | |
| <button | |
| type="button" | |
| onClick={onAbrir} | |
| className="w-full" | |
| className="w-full flex items-stretch" | |
| style={{ | |
| textAlign: "left", | |
| display: "block", | |
| padding: 10, | |
| borderRadius: 9, | |
| gap: 10, | |
| padding: 8, | |
| borderRadius: 10, | |
| background: "var(--surface-2, rgba(255,255,255,0.04))", | |
| border: `1px solid ${ACCENT}2b`, | |
| borderLeft: `3px solid ${ACCENT}`, | |
| opacity: atividade.concluido_em ? 0.55 : 1, | |
| }} | |
| > | |
| <div className="flex items-center justify-between gap-2"> | |
| <p style={{ fontSize: 12, fontWeight: 600 }} className="truncate"> | |
| {atividade.categoria || atividade.titulo || "Follow-up"} | |
| </p> | |
| <span style={{ fontSize: 10, color: ACCENT, flexShrink: 0 }}> | |
| {mostrarData ? `${dataCurta} · ${hora}` : hora} | |
| <div | |
| style={{ | |
| flexShrink: 0, | |
| width: 44, | |
| display: "flex", | |
| flexDirection: "column", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| borderRadius: 8, | |
| background: `${ACCENT}22`, | |
| border: `1px solid ${ACCENT}55`, | |
| color: ACCENT, | |
| padding: "4px 2px", | |
| }} | |
| > | |
| <span style={{ fontSize: 9, textTransform: "uppercase", fontWeight: 700, lineHeight: 1 }}> | |
| {diaSemana} | |
| </span> | |
| <span style={{ fontSize: 19, fontWeight: 800, lineHeight: 1.3 }}> | |
| {quando ? String(quando.getDate()).padStart(2, "0") : "--"} | |
| </span> | |
| <span style={{ fontSize: 9, fontWeight: 700, lineHeight: 1 }}> | |
| {quando ? MESES_CURTO[quando.getMonth()] : ""} | |
| </span> | |
| </div> | |
| <p style={{ fontSize: 11, color: "var(--text-3)", marginTop: 2 }} className="truncate"> | |
| {nome} | |
| {atividade.lead_empresa_nome ? ` · ${atividade.lead_empresa_nome}` : ""} | |
| </p> | |
| {atividade.concluido_em && ( | |
| <span style={{ fontSize: 9, color: "var(--success)" }}>✓ concluído</span> | |
| )} | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div className="flex items-center justify-between gap-2"> | |
| <p style={{ fontSize: 13, fontWeight: 700 }} className="truncate"> | |
| {atividade.categoria || atividade.titulo || "Follow-up"} | |
| </p> | |
| {hora && ( | |
| <span style={{ fontSize: 11, color: ACCENT, flexShrink: 0, fontWeight: 600 }}> | |
| {hora} | |
| </span> | |
| )} | |
| </div> | |
| <p style={{ fontSize: 11.5, color: "var(--text-2, #ddd)", marginTop: 2 }} className="truncate"> | |
| {nome} | |
| {atividade.lead_empresa_nome ? ` · ${atividade.lead_empresa_nome}` : ""} | |
| </p> | |
| {atividade.concluido_em && ( | |
| <span style={{ fontSize: 9, color: "var(--success)" }}>✓ concluído</span> | |
| )} | |
| </div> | |
| const nome = atividade.lead_nome || atividade.lead_telefone || "Lead sem nome" | |
| const quando = atividade.agendado_para ? new Date(atividade.agendado_para) : null | |
| const hora = quando | |
| ? quando.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit", timeZone: "America/Sao_Paulo" }) | |
| : "" | |
| const diaSemana = quando | |
| ? quando.toLocaleDateString("pt-BR", { weekday: "short", timeZone: "America/Sao_Paulo" }).replace(".", "") | |
| : "" |
🧰 Tools
🪛 React Doctor (0.7.6)
[error] 490-490: This can cause a hydration mismatch because toLocaleTimeString() formats with the server's locale and timezone during server rendering but the user's in the browser. Format it in a post-mount useEffect, or pass an explicit locale and timeZone.
Format locale/timezone-dependent values in a post-mount useEffect + state, or pass an explicit locale and timeZone so the server and the browser render the same text. Only runs on SSR-capable projects.
(no-locale-format-in-render)
[error] 493-493: This can cause a hydration mismatch because toLocaleDateString() formats with the server's locale and timezone during server rendering but the user's in the browser. Format it in a post-mount useEffect, or pass an explicit locale and timeZone.
Format locale/timezone-dependent values in a post-mount useEffect + state, or pass an explicit locale and timeZone so the server and the browser render the same text. Only runs on SSR-capable projects.
(no-locale-format-in-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 `@components/crm/Calendario.tsx` around lines 477 - 554, Update
CartaoCompromisso to use a single explicit timezone, such as America/Sao_Paulo,
for hora and diaSemana formatting, and derive the displayed day with the same
timezone-aware approach instead of quando.getDate(). Keep the existing pt-BR
formatting and card layout unchanged, ensuring SSR and hydration produce
identical values.
Source: Linters/SAST tools
| async function salvar() { | ||
| if (!quando) { | ||
| setStatus("Escolha data e hora.") | ||
| return | ||
| } | ||
| const fd = new FormData() | ||
| fd.set("lead_id", leadId) | ||
| fd.set("categoria", categoria) | ||
| fd.set("titulo", titulo) | ||
| fd.set("agendado_para", quando) | ||
| const r = await criarFollowUpAction(fd) | ||
| if (r.ok) { | ||
| setStatus("Marcado ✓") | ||
| router.refresh() | ||
| setTimeout(onClose, 700) | ||
| } else { | ||
| setStatus(r.erro ?? "Erro") | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== File list =="
git ls-files | rg '^(components/crm/Etiquetas\.tsx|.*follow.*up.*|.*crm_atividades.*|.*atividade.*)$' || true
echo
echo "== Outline of components/crm/Etiquetas.tsx =="
ast-grep outline components/crm/Etiquetas.tsx --view expanded || true
echo
echo "== Relevant lines in components/crm/Etiquetas.tsx =="
sed -n '360,470p' components/crm/Etiquetas.tsx
echo
echo "== Search for criarFollowUpAction =="
rg -n "criarFollowUpAction|crm_atividades|agendado_para|follow.?up" . --glob '!**/node_modules/**' --glob '!**/.next/**' || trueRepository: sejaanomalo-web/metas-anomalo
Length of output: 10931
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== lib/crm-atividades-actions.ts (create follow-up path) =="
sed -n '132,190p' lib/crm-atividades-actions.ts
echo
echo "== Table definition for public.crm_atividades =="
sed -n '102,130p' supabase/migrations/20260624_crm_fase0.sql
echo
echo "== Any uniqueness or dedup constraints on crm_atividades =="
rg -n "unique|UNIQUE|dedup|duplicate|on conflict|conflict" supabase/migrations lib/crm-atividades-actions.ts components/crm/Etiquetas.tsxRepository: sejaanomalo-web/metas-anomalo
Length of output: 7893
🌐 Web query:
React 18.3 useTransition startTransition async callback pending await semantics
💡 Result:
In React 18.3, the startTransition function (and the startTransition returned by useTransition) operates synchronously [1][2][3]. When you pass a callback to it, React executes that callback immediately to mark any state updates triggered during its synchronous execution as transitions [1][2][4]. Regarding async callbacks and await semantics: 1. Scope Limitation: The "transition" marking is strictly scoped to the synchronous portion of the callback execution [2][5]. If you await a promise, the execution context of the callback effectively ends for the purposes of marking state updates [2]. Any state updates scheduled after an await are not automatically marked as transitions [1][4][5]. 2. Requirement for Nested Wrapping: Because of the synchronous nature of the marking, any state update that occurs after an await within the callback must be wrapped in an additional startTransition call if you wish for that specific update to be treated as a transition [1][4][6]. Failure to do so will result in the update being treated as a standard, urgent (non-transition) update [2][5]. 3. useTransition Pending State: While useTransition allows you to pass an async function, the isPending state reflects the duration of the initial synchronous execution and any immediate effects; it does not automatically track the entire lifetime of an asynchronous operation across await boundaries [7][4]. Developers are responsible for managing the transition status manually across async steps using nested startTransition calls to ensure that subsequent updates remain grouped correctly [1][4][6]. These semantics exist because React currently lacks an Async Context API to implicitly associate state updates with an asynchronous call stack across ticks [7]. This behavior is a known limitation in the React 18 release line [1][4].
Citations:
- 1: https://react.dev/reference/react/startTransition
- 2: https://subhsen.dev/blog/starttransition-does-not-defer
- 3: New feature: startTransition reactwg/react-18#41
- 4: https://uk.react.dev/reference/react/useTransition
- 5: https://stackoverflow.com/questions/79290597/usetransitions-ispending-not-awaiting-api-call-completion-before-changing-to-fa
- 6: useTransition(): startTransition after await vercel/next.js#79549
- 7: Initial (client-only) async actions support react/react#26621
Guard salvar against re-entry. pending drops before the awaited insert finishes, so the button can be clicked again while the request is still in flight. Since criarFollowUpAction inserts unconditionally and there’s no uniqueness guard, a double click can create duplicate follow-ups.
🤖 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/Etiquetas.tsx` around lines 388 - 406, Update salvar to guard
against re-entry while criarFollowUpAction is in flight: return immediately when
the existing pending state indicates a submission is active, and set that state
before awaiting the action. Clear pending after the request completes,
preserving the current success and error handling so duplicate follow-ups cannot
be created by repeated clicks.
…o na etiqueta
Corrige o calendário cortando meses (flex-wrap quebrava o overflow:auto interno) e destaca dia/mês/semana em selo nos cartões de compromisso. Remove o botão de link do calendário (uso desnecessário por ora).
Unifica "+ Agendar" dentro do picker de etiqueta: marcar uma etapa do funil que implica compromisso (reunião/follow-up/fechamento) abre o mesmo formulário de data, com o detalhe pré-preenchido "Etapa com Fulano" (editável). Etapas sem compromisso associado só trocam a fase na hora. Novo estilo do menu (maior, com emoji por etapa), botão movido pro cabeçalho da conversa no lugar do antigo "+ Agendar".
Summary by CodeRabbit
New Features
Updates