From f7ad9730b6db50760f0f5fdb30932d47202467fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lan=20L=2E=20Silva?= Date: Tue, 6 Oct 2026 16:56:38 -0300 Subject: [PATCH] feat(app): add commercial CRM cockpit --- .../src/activities/activities.contracts.ts | 1 + apps/api/src/activities/activities.service.ts | 4 +- .../[slug]/cockpit/commercial-cockpit.tsx | 378 ++++++++++++++++++ apps/app/app/(app)/[slug]/page.tsx | 16 +- apps/app/components/app-icon-rail.tsx | 2 +- 5 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 apps/app/app/(app)/[slug]/cockpit/commercial-cockpit.tsx diff --git a/apps/api/src/activities/activities.contracts.ts b/apps/api/src/activities/activities.contracts.ts index ad984fded..8fe91d232 100644 --- a/apps/api/src/activities/activities.contracts.ts +++ b/apps/api/src/activities/activities.contracts.ts @@ -108,6 +108,7 @@ const activityContactRefOutput = z id: z.string(), firstName: z.string(), lastName: z.string().nullable(), + phone: z.string().nullable(), }) .nullable(); diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 50439fcbc..46e5fc849 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -38,7 +38,9 @@ const ENTRY_SELECT = { createdAt: true, createdBy: { select: AUTHOR_SELECT }, company: { select: { id: true, name: true } }, - contact: { select: { id: true, firstName: true, lastName: true } }, + contact: { + select: { id: true, firstName: true, lastName: true, phone: true }, + }, deal: { select: { id: true, name: true } }, emailThread: { diff --git a/apps/app/app/(app)/[slug]/cockpit/commercial-cockpit.tsx b/apps/app/app/(app)/[slug]/cockpit/commercial-cockpit.tsx new file mode 100644 index 000000000..0dac76de8 --- /dev/null +++ b/apps/app/app/(app)/[slug]/cockpit/commercial-cockpit.tsx @@ -0,0 +1,378 @@ +"use client"; + +import Phone from "@carbon/icons-react/es/Phone"; +import Task from "@carbon/icons-react/es/Task"; +import type { DealStage } from "@crm/db/enums"; +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardDescription, + CardHeader, + CardPanel, + CardTitle, +} from "@crm/ui/components/card"; +import { Icon } from "@crm/ui/components/icon"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@crm/ui/components/tabs"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; +import { LocalDay } from "@/components/local-date-time"; +import { dealStageLabel } from "@/lib/deal-stage"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; + +type TaskEntry = RouterOutputs["activities"]["myTasks"][number]; +type DealRow = RouterOutputs["deals"]["list"]["rows"][number]; + +type Bucket = "overdue" | "today" | "upcoming" | "no-date"; + +const BUCKETS: Array<{ id: Bucket; label: string; description: string }> = [ + { + id: "overdue", + label: "Atrasados", + description: "Ações que já passaram do prazo", + }, + { id: "today", label: "Hoje", description: "Ações para executar agora" }, + { id: "upcoming", label: "Próximos", description: "Ações dos próximos dias" }, + { + id: "no-date", + label: "Sem data", + description: "Tarefas que precisam de planejamento", + }, +]; + +function dayKey(value: Date): string { + return `${value.getFullYear()}-${value.getMonth()}-${value.getDate()}`; +} + +function bucketFor(task: TaskEntry): Bucket { + if (!task.dueAt) return "no-date"; + const due = new Date(task.dueAt); + const today = new Date(); + if (due < new Date(today.getFullYear(), today.getMonth(), today.getDate())) { + return "overdue"; + } + if (dayKey(due) === dayKey(today)) return "today"; + return "upcoming"; +} + +function taskLabel(task: TaskEntry): string { + return task.subject ?? task.body ?? "Follow-up comercial"; +} + +function whatsappUrl(task: TaskEntry): string | null { + const phone = task.contact?.phone?.replace(/\D/g, ""); + if (!phone) return null; + const text = + task.body || + `Olá${task.contact?.firstName ? `, ${task.contact.firstName}` : ""}! Passando para retomar nossa conversa. Podemos avançar por aqui?`; + return `https://wa.me/${phone}?text=${encodeURIComponent(text)}`; +} + +function TaskRow({ task }: { task: TaskEntry }) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const openRecord = useOpenRecord(); + const complete = useMutation( + trpc.activities.complete.mutationOptions({ + onSuccess: () => cache.activity(), + onError: (error) => toast.error(error.message), + }), + ); + const whatsapp = whatsappUrl(task); + const isCall = + (task.subject?.toLowerCase().includes("ligação") || + task.subject?.toLowerCase().includes("ligar")) ?? + false; + const isPreview = task.subject?.toLowerCase().includes("prévia") ?? false; + const cleanPhone = task.contact?.phone?.replace(/\D/g, ""); + const record = task.deal + ? { kind: "deal" as const, id: task.deal.id } + : task.contact + ? { kind: "contact" as const, id: task.contact.id } + : task.company + ? { kind: "company" as const, id: task.company.id } + : null; + + return ( +
+
+ +
+ {task.company ? ( + + {task.company.name} + + ) : null} + {task.contact ? ( + + • {task.contact.firstName} {task.contact.lastName ?? ""}{" "} + {task.contact.phone ? `(${task.contact.phone})` : ""} + + ) : null} + {task.dueAt ? ( + + • Prazo: + + ) : ( + • Definir data + )} +
+ {task.body && task.body !== task.subject && ( +
+ + {isCall + ? "Roteiro de ligação:" + : isPreview + ? "Brief da prévia:" + : "Mensagem IA (WhatsApp):"} + + {task.body} +
+ )} +
+
+ {isCall && cleanPhone ? ( + + ) : whatsapp ? ( + + ) : null} + +
+
+ ); +} + +const KANBAN_STAGES: DealStage[] = [ + "DEMO_BOOKED", + "QUALIFIED_TO_BUY", + "DECISION_MAKER_BOUGHT_IN", + "CONTRACT_SENT", + "CLOSED_WON", + "CLOSED_LOST", + "UNQUALIFIED_TO_BUY", +]; + +function Kanban({ rows }: { rows: DealRow[] }) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const openRecord = useOpenRecord(); + const workspaceUrl = useWorkspaceUrl(); + const [pendingDealId, setPendingDealId] = useState(null); + const requestPreview = useMutation( + trpc.activities.create.mutationOptions({ + onSuccess: () => { + setPendingDealId(null); + cache.activity(); + toast.success("Solicitação registrada nas ações comerciais."); + }, + onError: (error) => { + setPendingDealId(null); + toast.error(error.message); + }, + }), + ); + const createPreviewTask = (deal: DealRow) => { + setPendingDealId(deal.id); + requestPreview.mutate({ + type: "TASK", + subject: `Produzir prévia do site: ${deal.company.name}`, + body: `Criar uma prévia visual para ${deal.company.name} com base no contexto deste Deal. Adicione o link da prévia ao registro quando estiver pronta.`, + dealId: deal.id, + }); + }; + if (rows.length === 0) { + return ( +
+

Nenhuma oportunidade no Kanban.

+

+ Crie um Deal para acompanhar a oportunidade por estágio. +

+ +
+ ); + } + + return ( +
+ {KANBAN_STAGES.map((stage) => { + const deals = rows.filter((deal) => deal.stage === stage); + return ( +
+
+

{dealStageLabel(stage)}

+ + {deals.length} + +
+
+ {deals.map((deal) => ( +
+ +
+ {deal.company.name} +
+
+ {deal.fields.linha_de_negocio ? ( + + {String(deal.fields.linha_de_negocio)} + + ) : null} + {deal.fields.produto ? ( + + {String(deal.fields.produto)} + + ) : null} +
+ +
+ ))} +
+
+ ); + })} +
+ ); +} + +export function CommercialCockpit() { + const trpc = useTRPC(); + const [view, setView] = useState<"actions" | "kanban">("actions"); + const tasks = useQuery({ + ...trpc.activities.myTasks.queryOptions({ window: "all", limit: 100 }), + refetchInterval: 60_000, + }); + const deals = useQuery( + trpc.deals.list.queryOptions({ + q: "", + sort: "createdAt", + dir: "desc", + page: 1, + pageSize: 100, + status: "all", + owner: [], + stage: [], + closing: [], + fields: {}, + archived: false, + }), + ); + const grouped = useMemo(() => { + const result = new Map( + BUCKETS.map(({ id }) => [id, []]), + ); + for (const task of tasks.data ?? []) { + result.get(bucketFor(task))?.push(task); + } + return result; + }, [tasks.data]); + + return ( + setView(value as "actions" | "kanban")} + > + + Ações comerciais + Kanban + + +
+ {BUCKETS.map((bucket) => { + const rows = grouped.get(bucket.id) ?? []; + return ( + + + + + {bucket.label} + + {bucket.description} + + + {rows.length === 0 ? ( +

+ Nenhum follow-up nesta fila. +

+ ) : ( + rows.map((task) => ) + )} +
+
+ ); + })} +
+
+ + + +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/page.tsx b/apps/app/app/(app)/[slug]/page.tsx index feaf6017e..017f4f9b0 100644 --- a/apps/app/app/(app)/[slug]/page.tsx +++ b/apps/app/app/(app)/[slug]/page.tsx @@ -10,6 +10,7 @@ import { import { requireSession } from "@/lib/session"; import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { CommercialCockpit } from "./cockpit/commercial-cockpit"; import { DashboardSummary } from "./dashboard-summary"; import { OverviewGreeting, @@ -55,12 +56,21 @@ async function Summary({ ]); const queryClient = getServerQueryClient(); - await queryClient.prefetchQuery( - getServerTrpc().dashboard.summary.queryOptions({ scope }), - ); + await Promise.all([ + queryClient.prefetchQuery( + getServerTrpc().dashboard.summary.queryOptions({ scope }), + ), + queryClient.prefetchQuery( + getServerTrpc().activities.myTasks.queryOptions({ + window: "all", + limit: 100, + }), + ), + ]); return ( + ); diff --git a/apps/app/components/app-icon-rail.tsx b/apps/app/components/app-icon-rail.tsx index ad3440e00..6ecaf97f9 100644 --- a/apps/app/components/app-icon-rail.tsx +++ b/apps/app/components/app-icon-rail.tsx @@ -40,7 +40,7 @@ type RailItem = { }; const ITEMS: RailItem[] = [ - { title: "Overview", href: "/", icon: Dashboard, match: "exact" }, + { title: "Cockpit", href: "/", icon: Dashboard, match: "exact" }, { title: "Chat", href: "/chat",