diff --git a/components/crm/Calendario.tsx b/components/crm/Calendario.tsx index 4499762..7fbedcf 100644 --- a/components/crm/Calendario.tsx +++ b/components/crm/Calendario.tsx @@ -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"] @@ -145,7 +144,6 @@ export default function Calendario({ const [periodo, setPeriodo] = useState("este_mes") const [diaFoco, setDiaFoco] = useState(null) const [aberta, setAberta] = useState(null) - const [linkStatus, setLinkStatus] = useState(null) const router = useRouter() const { inicio, fim } = useMemo(() => calcularRange(periodo, hoje), [periodo, hoje]) @@ -180,22 +178,6 @@ 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) @@ -203,7 +185,7 @@ export default function Calendario({ return (
- {/* Toolbar: seletor de período + link do calendário */} + {/* Toolbar: seletor de período */}
-
- {linkStatus && ( - - {linkStatus} - - )} - -
- {/* 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. */}
{/* Calendário visual (um mini-mês por mês do período) */}
{proximas.map((a) => ( - setAberta(a)} mostrarData /> + setAberta(a)} /> ))}
)} @@ -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 ( ) } diff --git a/components/crm/Etiquetas.tsx b/components/crm/Etiquetas.tsx index 844bec9..f634e11 100644 --- a/components/crm/Etiquetas.tsx +++ b/components/crm/Etiquetas.tsx @@ -4,8 +4,14 @@ import { useState, useTransition } from "react" import { useRouter } from "next/navigation" import { atribuirEtiquetaAction, - removerEtiquetaAction, } from "@/lib/crm-etiquetas-actions" +import { + criarFollowUpAction, + criarTipoAtividadeAction, + excluirTipoAtividadeAction, +} from "@/lib/crm-atividades-actions" +import type { CrmTipoAtividadeRow } from "@/lib/crm-atividades-actions" +import { agoraLocalInput, emojiDaEtapa, precisaAgendar } from "@/lib/crm-tipos-atividade" interface EtiquetaResumo { id: string @@ -41,7 +47,7 @@ export function EtiquetaChip({ whiteSpace: "nowrap", }} > - {nome} + {emojiDaEtapa(nome)} {nome} {onRemove && ( {aberto && ( @@ -110,7 +143,7 @@ export default function EtiquetasPicker({ + ) + })} + {todasEtiquetas.length === 0 && ( +

+ Nenhuma etapa ainda. Crie fases no Kanban. +

+ )} +
+ + {tiposCustom.length > 0 && ( + <> +

+ Tipos personalizados +

+
+ {tiposCustom.map((t) => ( +
+
+ 🏷️ + {t.nome} + + +
+ ))} )} + +
+ setNovoTipo(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + criarTipo() + } + }} + placeholder="Novo tipo de atividade..." + maxLength={60} + className="glass-input" + style={{ flex: 1, fontSize: 12, padding: "6px 8px" }} + /> + +
+ + ) +} + +/** Formulário de data — reaproveitado tanto pras etapas de agendamento + * quanto pros tipos personalizados. `categoria` decide a fase que + * criarFollowUpAction tenta aplicar ao lead (só move quando o nome bate com + * uma etapa existente — ver sincronizarLeadComEtapaDaCategoria). */ +function FormAgendar({ + leadId, + categoria, + tituloInicial, + onVoltar, + onClose, +}: { + leadId: string + categoria: string + tituloInicial: string + onVoltar: () => void + onClose: () => void +}) { + const [titulo, setTitulo] = useState(tituloInicial) + const [quando, setQuando] = useState(agoraLocalInput()) + const [pending, startTransition] = useTransition() + const [status, setStatus] = useState(null) + const router = useRouter() + + 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") + } + } + + return ( +
+
+ + {emojiDaEtapa(categoria)} +

{categoria}

+
+ setTitulo(e.target.value)} + placeholder="Detalhe (ex: com o nome do contato)" + maxLength={100} + className="glass-input" + style={{ fontSize: 12, padding: "6px 10px" }} + /> + setQuando(e.target.value)} + className="glass-input" + style={{ fontSize: 12, padding: "6px 10px" }} + /> +
+ + + {status && ( + + {status} + + )} +
) } diff --git a/components/crm/Thread.tsx b/components/crm/Thread.tsx index cf09458..49d2c16 100644 --- a/components/crm/Thread.tsx +++ b/components/crm/Thread.tsx @@ -11,11 +11,6 @@ import { marcarLeadComoLidoAction, carregarMensagensAntigasAction, } from "@/lib/crm-mensagens-actions" -import { - criarFollowUpAction, - criarTipoAtividadeAction, - excluirTipoAtividadeAction, -} from "@/lib/crm-atividades-actions" import { renomearLeadAction, sincronizarContatoAction, @@ -23,7 +18,6 @@ import { excluirLeadAction, desarquivarLeadAction, } from "@/lib/crm-leads-actions" -import { TIPOS_ATIVIDADE_PADRAO } from "@/lib/crm-tipos-atividade" import Avatar from "@/components/crm/Avatar" import EtiquetasPicker, { EtiquetaChip } from "@/components/crm/Etiquetas" @@ -46,13 +40,6 @@ function formatarHora(iso: string | null): string { }) } -/** "agora" local no formato do input datetime-local (YYYY-MM-DDTHH:mm). */ -function agoraLocalInput(): string { - const d = new Date() - const local = new Date(d.getTime() - d.getTimezoneOffset() * 60_000) - return local.toISOString().slice(0, 16) -} - export default function Thread({ lead, mensagensIniciais, @@ -189,21 +176,24 @@ export default function Thread({
- +
-
- {lead.etiquetas.map((e) => ( - - ))} - -
+ {lead.etiquetas.length > 0 && ( +
+ {lead.etiquetas.map((e) => ( + + ))} +
+ )}
(null) - - return ( -
- - - {aberto && ( - <> -
- ) -} - -function MenuTipos({ - tiposCustom, - onEscolher, -}: { - tiposCustom: CrmTipoAtividadeRow[] - onEscolher: (categoria: string) => void -}) { - const [novoTipo, setNovoTipo] = useState("") - const [pending, startTransition] = useTransition() - const router = useRouter() - - function criarTipo() { - const nome = novoTipo.trim() - if (!nome) return - startTransition(async () => { - const fd = new FormData() - fd.set("nome", nome) - await criarTipoAtividadeAction(fd) - setNovoTipo("") - router.refresh() - }) - } - - function excluirTipo(id: string) { - startTransition(async () => { - const fd = new FormData() - fd.set("id", id) - await excluirTipoAtividadeAction(fd) - router.refresh() - }) - } - - return ( -
-

- Registrar ponto de contato -

-
- {TIPOS_ATIVIDADE_PADRAO.map((t) => ( - - ))} - {tiposCustom.map((t) => ( -
- - -
- ))} -
-
- setNovoTipo(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - criarTipo() - } - }} - placeholder="Novo tipo de atividade..." - maxLength={60} - className="glass-input" - style={{ flex: 1, fontSize: 12, padding: "6px 8px" }} - /> - -
-
- ) -} - -function FormAtividade({ - leadId, - categoria, - onVoltar, - onClose, -}: { - leadId: string - categoria: string - onVoltar: () => void - onClose: () => void -}) { - const [titulo, setTitulo] = useState("") - const [quando, setQuando] = useState(agoraLocalInput()) - const [pending, startTransition] = useTransition() - const [status, setStatus] = useState(null) - const router = useRouter() - - 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") - } - } - - return ( -
-
- -

{categoria}

-
- setTitulo(e.target.value)} - placeholder="Detalhe (opcional)" - maxLength={100} - className="glass-input" - style={{ fontSize: 12, padding: "6px 10px" }} - /> - setQuando(e.target.value)} - className="glass-input" - style={{ fontSize: 12, padding: "6px 10px" }} - /> -
- - - {status && ( - - {status} - - )} -
-
- ) -} diff --git a/lib/crm-tipos-atividade.ts b/lib/crm-tipos-atividade.ts index a603f4e..287d5f7 100644 --- a/lib/crm-tipos-atividade.ts +++ b/lib/crm-tipos-atividade.ts @@ -33,3 +33,47 @@ export function tipoTecnicoDaCategoria(categoria: string): "tarefa" | "reuniao" } return "tarefa" } + +function semAcentoMinusculo(s: string): string { + return s + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim() +} + +/** Etapas/tipos cujo nome indica um compromisso com hora marcada (reunião, + * follow-up, fechamento) — pra essas o picker de etiqueta abre o formulário + * de agendar em vez de só trocar a fase na hora. */ +export function precisaAgendar(nome: string): boolean { + const n = semAcentoMinusculo(nome) + return ( + n.includes("reuni") || + n.includes("agend") || + n.includes("follow") || + n.includes("fechamento") + ) +} + +/** Emoji por palavra-chave do nome da etapa — só estética, tolerante a + * rename (cai no rótulo genérico 🏷️ quando não reconhece nada). */ +export function emojiDaEtapa(nome: string): string { + const n = semAcentoMinusculo(nome) + if (n.includes("novo")) return "👋" + if (n.includes("conversa")) return "💬" + if (n.includes("qualific")) return "🎯" + if (n.includes("reuni") || n.includes("agend")) return "📅" + if (n.includes("proposta")) return "📩" + if (n.includes("follow")) return "🔁" + if (n.includes("fechamento")) return "✅" + if (n.includes("cliente") || n.includes("ganho")) return "🏆" + if (n.includes("perdid")) return "❌" + return "🏷️" +} + +/** "agora" local no formato do input datetime-local (YYYY-MM-DDTHH:mm). */ +export function agoraLocalInput(): string { + const d = new Date() + const local = new Date(d.getTime() - d.getTimezoneOffset() * 60_000) + return local.toISOString().slice(0, 16) +}