Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 60 additions & 72 deletions components/crm/Calendario.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { CrmAtividadeRow } from "@/lib/crm-atividades-actions"
import {
concluirAtividadeAction,
excluirAtividadeAction,
gerarLinkCalendarioAction,
} from "@/lib/crm-atividades-actions"

const DIAS_SEMANA = ["D", "S", "T", "Q", "Q", "S", "S"]
Expand Down Expand Up @@ -145,7 +144,6 @@ export default function Calendario({
const [periodo, setPeriodo] = useState<PeriodoKey>("este_mes")
const [diaFoco, setDiaFoco] = useState<string | null>(null)
const [aberta, setAberta] = useState<CrmAtividadeRow | null>(null)
const [linkStatus, setLinkStatus] = useState<string | null>(null)
const router = useRouter()

const { inicio, fim } = useMemo(() => calcularRange(periodo, hoje), [periodo, hoje])
Expand Down Expand Up @@ -180,30 +178,14 @@ export default function Calendario({

const periodoLabel = PERIODOS.find((p) => p.chave === periodo)?.label ?? ""

async function copiarLink() {
const r = await gerarLinkCalendarioAction()
if (r.ok && r.token) {
const url = `${window.location.origin}/api/crm/calendario/${r.token}/feed.ics`
try {
await navigator.clipboard.writeText(url)
setLinkStatus("Link copiado ✓")
} catch {
setLinkStatus(url)
}
setTimeout(() => setLinkStatus(null), 4000)
} else {
setLinkStatus(r.erro ?? "Erro ao gerar link")
}
}

function trocarPeriodo(p: PeriodoKey) {
setPeriodo(p)
setDiaFoco(null)
}

return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", gap: 14 }}>
{/* Toolbar: seletor de período + link do calendário */}
{/* Toolbar: seletor de período */}
<div
className="flex items-center justify-between flex-wrap gap-3"
style={{ flexShrink: 0 }}
Expand All @@ -228,38 +210,16 @@ export default function Calendario({
{totalNoPeriodo} compromisso{totalNoPeriodo === 1 ? "" : "s"}
</span>
</div>
<div className="flex items-center gap-2">
{linkStatus && (
<span
style={{
fontSize: 10,
color: linkStatus.includes("✓") ? "var(--success)" : "var(--text-3)",
maxWidth: 220,
wordBreak: "break-all",
}}
>
{linkStatus}
</span>
)}
<button
type="button"
onClick={copiarLink}
style={{
fontSize: 11,
color: "var(--text-3)",
border: "0.5px solid rgba(255,255,255,0.15)",
borderRadius: 8,
padding: "6px 10px",
}}
>
📋 Link do calendário
</button>
</div>
</div>

{/* Conteúdo: calendário multi-mês + painel de compromissos */}
{/* Conteúdo: calendário multi-mês + painel de compromissos.
SEM flexWrap: com wrap, a "linha" do flex passa a se dimensionar
pelo conteúdo em vez de esticar pra altura do pai — é isso que
fazia o minHeight:0 + overflowY:auto de baixo nunca ter uma altura
fixa contra a qual rolar, e o container externo (overflow:hidden)
simplesmente cortava os meses excedentes. */}
<div
style={{ flex: 1, minHeight: 0, display: "flex", gap: 16, flexWrap: "wrap" }}
style={{ flex: 1, minHeight: 0, display: "flex", gap: 16 }}
>
{/* Calendário visual (um mini-mês por mês do período) */}
<div
Expand Down Expand Up @@ -378,7 +338,7 @@ export default function Calendario({
) : (
<div className="space-y-2">
{proximas.map((a) => (
<CartaoCompromisso key={a.id} atividade={a} onAbrir={() => setAberta(a)} mostrarData />
<CartaoCompromisso key={a.id} atividade={a} onAbrir={() => setAberta(a)} />
))}
</div>
)}
Expand Down Expand Up @@ -514,56 +474,84 @@ function MiniMes({
)
}

/** Cartão clicável de um compromisso (na lista do período e nos próximos). */
/** 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>
Comment on lines +477 to +554

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Suggested change
/** 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

</button>
)
}
Expand Down
Loading