From 7a3eca936194bc35a68471be8d21d32fc2604251 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Sat, 18 Oct 2025 21:13:20 -0300 Subject: [PATCH] feat: sync convex companies and dashboard metrics --- convex/companies.ts | 21 +++ convex/reports.ts | 21 +++ scripts/prune-convex-companies.mjs | 54 +++++++ src/app/api/admin/companies/[id]/route.ts | 26 +++- src/app/api/admin/companies/route.ts | 14 ++ src/app/api/machines/companies/route.ts | 14 +- src/app/dashboard/page.tsx | 8 +- src/components/charts/chart-open-priority.tsx | 143 ++++++++++++++++++ src/components/section-cards.tsx | 30 +++- src/server/companies-sync.ts | 44 ++++++ 10 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 scripts/prune-convex-companies.mjs create mode 100644 src/components/charts/chart-open-priority.tsx create mode 100644 src/server/companies-sync.ts diff --git a/convex/companies.ts b/convex/companies.ts index 116eaa8..51bff83 100644 --- a/convex/companies.ts +++ b/convex/companies.ts @@ -85,3 +85,24 @@ export const ensureProvisioned = mutation({ } }, }) + +export const removeBySlug = mutation({ + args: { + tenantId: v.string(), + slug: v.string(), + }, + handler: async (ctx, { tenantId, slug }) => { + const normalizedSlug = normalizeSlug(slug) ?? slug + const existing = await ctx.db + .query("companies") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", normalizedSlug)) + .unique() + + if (!existing) { + return { removed: false } + } + + await ctx.db.delete(existing._id) + return { removed: true } + }, +}) diff --git a/convex/reports.ts b/convex/reports.ts index 59a64e7..330d55a 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -347,6 +347,22 @@ export const dashboardOverview = query({ const trend = percentageChange(newTickets.length, previousTickets.length); + const inProgressCurrent = tickets.filter((ticket) => { + if (!ticket.firstResponseAt) return false; + const status = normalizeStatus(ticket.status); + if (status === "RESOLVED") return false; + return !ticket.resolvedAt; + }); + + const inProgressPrevious = tickets.filter((ticket) => { + if (!ticket.firstResponseAt || ticket.firstResponseAt >= lastDayStart) return false; + if (ticket.resolvedAt && ticket.resolvedAt < lastDayStart) return false; + const status = normalizeStatus(ticket.status); + return status !== "RESOLVED" || !ticket.resolvedAt; + }); + + const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); + const lastWindowStart = now - 7 * ONE_DAY_MS; const previousWindowStart = now - 14 * ONE_DAY_MS; @@ -396,6 +412,11 @@ export const dashboardOverview = query({ previous24h: previousTickets.length, trendPercentage: trend, }, + inProgress: { + current: inProgressCurrent.length, + previousSnapshot: inProgressPrevious.length, + trendPercentage: inProgressTrend, + }, firstResponse: { averageMinutes: averageWindow, previousAverageMinutes: averagePrevious, diff --git a/scripts/prune-convex-companies.mjs b/scripts/prune-convex-companies.mjs new file mode 100644 index 0000000..a3647bf --- /dev/null +++ b/scripts/prune-convex-companies.mjs @@ -0,0 +1,54 @@ +import "dotenv/config" +import { PrismaClient } from "@prisma/client" +import { ConvexHttpClient } from "convex/browser" + +const prisma = new PrismaClient() + +const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas" +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210" +const secret = process.env.CONVEX_SYNC_SECRET + +if (!secret) { + console.error("CONVEX_SYNC_SECRET não configurado. Configure-o para executar o prune.") + process.exit(1) +} + +async function main() { + const client = new ConvexHttpClient(convexUrl) + + const prismaCompanies = await prisma.company.findMany({ + where: { tenantId }, + select: { slug: true }, + }) + const validSlugs = new Set(prismaCompanies.map((company) => company.slug)) + + const snapshot = await client.query("migrations:exportTenantSnapshot", { + tenantId, + secret, + }) + + const extraCompanies = snapshot.companies.filter((company) => !validSlugs.has(company.slug)) + if (extraCompanies.length === 0) { + console.log("Nenhuma empresa extra encontrada no Convex.") + return + } + + console.log(`Removendo ${extraCompanies.length} empresa(s) do Convex: ${extraCompanies.map((c) => c.slug).join(", ")}`) + + for (const company of extraCompanies) { + await client.mutation("companies:removeBySlug", { + tenantId, + slug: company.slug, + }) + } + console.log("Concluído.") +} + +main() + .catch((error) => { + console.error("Falha ao remover empresas extras do Convex", error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/src/app/api/admin/companies/[id]/route.ts b/src/app/api/admin/companies/[id]/route.ts index 6ddcd04..29ffcc7 100644 --- a/src/app/api/admin/companies/[id]/route.ts +++ b/src/app/api/admin/companies/[id]/route.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma" import { assertStaffSession } from "@/lib/auth-server" import { isAdmin } from "@/lib/authz" import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library" +import { removeConvexCompany, syncConvexCompany } from "@/server/companies-sync" export const runtime = "nodejs" @@ -45,6 +46,19 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id // Atualize o client quando possível; por ora liberamos o shape dinamicamente. // eslint-disable-next-line @typescript-eslint/no-explicit-any const company = await prisma.company.update({ where: { id }, data: updates as any }) + + if (company.provisioningCode) { + const synced = await syncConvexCompany({ + tenantId: company.tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + if (!synced) { + console.warn("[admin.companies] Convex não configurado; atualização aplicada apenas no Prisma.") + } + } + return NextResponse.json({ company }) } catch (error) { console.error("Failed to update company", error) @@ -65,7 +79,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str const company = await prisma.company.findUnique({ where: { id }, - select: { id: true, tenantId: true, name: true }, + select: { id: true, tenantId: true, name: true, slug: true }, }) if (!company) { @@ -90,6 +104,16 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str return { detachedUsers: users.count, detachedTickets: tickets.count } }) + if (company.slug) { + const removed = await removeConvexCompany({ + tenantId: company.tenantId, + slug: company.slug, + }) + if (!removed) { + console.warn("[admin.companies] Convex não configurado; empresa removida apenas no Prisma.") + } + } + return NextResponse.json({ ok: true, ...result }) } catch (error) { if (error instanceof PrismaClientKnownRequestError && error.code === "P2003") { diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts index fba703f..e4286a8 100644 --- a/src/app/api/admin/companies/route.ts +++ b/src/app/api/admin/companies/route.ts @@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma" import { assertStaffSession } from "@/lib/auth-server" import { isAdmin } from "@/lib/authz" import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library" +import { syncConvexCompany } from "@/server/companies-sync" export const runtime = "nodejs" @@ -64,6 +65,19 @@ export async function POST(request: Request) { address: address ? String(address) : null, }, }) + + if (company.provisioningCode) { + const synced = await syncConvexCompany({ + tenantId: company.tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + if (!synced) { + console.warn("[admin.companies] Convex não configurado; empresa criada apenas no Prisma.") + } + } + return NextResponse.json({ company }) } catch (error) { console.error("Failed to create company", error) diff --git a/src/app/api/machines/companies/route.ts b/src/app/api/machines/companies/route.ts index fd8022e..addb01a 100644 --- a/src/app/api/machines/companies/route.ts +++ b/src/app/api/machines/companies/route.ts @@ -1,12 +1,12 @@ import { randomBytes } from "crypto" import { Prisma } from "@prisma/client" -import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { env } from "@/lib/env" import { normalizeSlug, slugify } from "@/lib/slug" import { prisma } from "@/lib/prisma" import { createCorsPreflight, jsonWithCors } from "@/server/cors" -import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { ConvexConfigurationError } from "@/server/convex-client" +import { syncConvexCompany } from "@/server/companies-sync" export const runtime = "nodejs" @@ -31,11 +31,6 @@ function extractSecret(request: Request, url: URL): string | null { return null } -async function ensureConvexCompany(params: { tenantId: string; slug: string; name: string; provisioningCode: string }) { - const client = createConvexClient() - await client.mutation(api.companies.ensureProvisioned, params) -} - export async function OPTIONS(request: Request) { return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) } @@ -158,12 +153,15 @@ export async function POST(request: Request) { })) try { - await ensureConvexCompany({ + const synced = await syncConvexCompany({ tenantId, slug: company.slug, name: company.name, provisioningCode: company.provisioningCode, }) + if (!synced) { + throw new ConvexConfigurationError() + } } catch (error) { if (error instanceof ConvexConfigurationError) { return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index c5a51e2..c4800cb 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -4,6 +4,7 @@ import { SiteHeader } from "@/components/site-header" import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { ChartOpenedResolved } from "@/components/charts/chart-opened-resolved" +import { ChartOpenByPriority } from "@/components/charts/chart-open-priority" import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client" import { requireAuthenticatedSession } from "@/lib/auth-server" @@ -20,9 +21,12 @@ export default async function Dashboard() { /> } > - +
- +
+ + +
diff --git a/src/components/charts/chart-open-priority.tsx b/src/components/charts/chart-open-priority.tsx new file mode 100644 index 0000000..a7039c1 --- /dev/null +++ b/src/components/charts/chart-open-priority.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import { Bar, BarChart, CartesianGrid, Cell, XAxis } from "recharts" +import { useQuery } from "convex/react" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { useAuth } from "@/lib/auth-client" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { usePersistentCompanyFilter } from "@/lib/use-company-filter" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { Skeleton } from "@/components/ui/skeleton" + +type PriorityKey = "LOW" | "MEDIUM" | "HIGH" | "URGENT" + +const PRIORITY_CONFIG: Record = { + LOW: { label: "Baixa", color: "var(--chart-4)" }, + MEDIUM: { label: "Média", color: "var(--chart-3)" }, + HIGH: { label: "Alta", color: "var(--chart-2)" }, + URGENT: { label: "Crítica", color: "var(--chart-1)" }, +} + +const PRIORITY_ORDER: PriorityKey[] = ["URGENT", "HIGH", "MEDIUM", "LOW"] + +export function ChartOpenByPriority() { + const [timeRange, setTimeRange] = React.useState("30d") + const [companyId, setCompanyId] = usePersistentCompanyFilter("all") + const { session, convexUserId, isStaff } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + + const enabled = Boolean(isStaff && convexUserId) + + const report = useQuery( + api.reports.backlogOverview, + enabled + ? ({ + tenantId, + viewerId: convexUserId as Id<"users">, + range: timeRange, + companyId: companyId === "all" ? undefined : (companyId as Id<"companies">), + }) + : "skip" + ) as { priorityCounts: Record } | undefined + + const companies = useQuery( + api.companies.list, + enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + ) as Array<{ id: Id<"companies">; name: string }> | undefined + + if (!report) { + return + } + + const chartData = PRIORITY_ORDER.map((key) => ({ + priority: PRIORITY_CONFIG[key].label, + value: report.priorityCounts?.[key] ?? 0, + fill: PRIORITY_CONFIG[key].color, + })) + + const totalTickets = chartData.reduce((sum, item) => sum + item.value, 0) + + const chartConfig: ChartConfig = { + value: { + label: "Tickets em atendimento", + }, + } + + return ( + + + Tickets em andamento por prioridade + Distribuição de tickets iniciados e ainda abertos + +
+ + value && setTimeRange(value)} + variant="outline" + className="hidden *:data-[slot=toggle-group-item]:!px-4 @[640px]/card:flex" + > + 90 dias + 30 dias + 7 dias + +
+
+
+ + {totalTickets === 0 ? ( +
+ Sem tickets em andamento no período selecionado. +
+ ) : ( + + + + + } + /> + + {chartData.map((entry) => ( + + ))} + + + + )} +
+
+ ) +} diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx index 2ac0b09..1a6f1b5 100644 --- a/src/components/section-cards.tsx +++ b/src/components/section-cards.tsx @@ -38,9 +38,23 @@ export function SectionCards() { dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) + const inProgressSummary = useMemo(() => { + if (dashboard?.inProgress) { + return dashboard.inProgress + } + if (dashboard?.newTickets) { + return { + current: dashboard.newTickets.last24h, + previousSnapshot: dashboard.newTickets.previous24h, + trendPercentage: dashboard.newTickets.trendPercentage, + } + } + return null + }, [dashboard]) + const trendInfo = useMemo(() => { - if (!dashboard?.newTickets) return { value: null, label: "Aguardando dados", icon: IconTrendingUp } - const trend = dashboard.newTickets.trendPercentage + if (!inProgressSummary) return { value: null, label: "Aguardando dados", icon: IconTrendingUp } + const trend = inProgressSummary.trendPercentage if (trend === null) { return { value: null, label: "Sem histórico", icon: IconTrendingUp } } @@ -48,7 +62,7 @@ export function SectionCards() { const icon = positive ? IconTrendingUp : IconTrendingDown const label = `${positive ? "+" : ""}${trend.toFixed(1)}%` return { value: trend, label, icon } - }, [dashboard]) + }, [inProgressSummary]) const responseDelta = useMemo(() => { if (!dashboard?.firstResponse) return { delta: null, label: "Sem dados", positive: false } @@ -88,9 +102,9 @@ export function SectionCards() {
- Tickets novos + Tickets em atendimento - {dashboard ? dashboard.newTickets.last24h : } + {inProgressSummary ? inProgressSummary.current : } = 0 - ? "Volume acima do período anterior" - : "Volume abaixo do período anterior"} + ? "Mais tickets em andamento que no período anterior" + : "Menos tickets em andamento que no período anterior"}
- Comparação com as 24h anteriores. + Considera tickets iniciados (com 1ª resposta) e ainda sem resolução. Comparação com o mesmo horário das últimas 24h. diff --git a/src/server/companies-sync.ts b/src/server/companies-sync.ts new file mode 100644 index 0000000..d9c156b --- /dev/null +++ b/src/server/companies-sync.ts @@ -0,0 +1,44 @@ +import { api } from "@/convex/_generated/api" +import { createConvexClient, ConvexConfigurationError } from "./convex-client" + +type EnsureParams = { + tenantId: string + slug: string + name: string + provisioningCode: string +} + +type RemoveParams = { + tenantId: string + slug: string +} + +export async function syncConvexCompany(params: EnsureParams): Promise { + try { + const client = createConvexClient() + const result = await client.mutation(api.companies.ensureProvisioned, params) + return Boolean(result) + } catch (error) { + if (error instanceof ConvexConfigurationError) { + console.warn("[companies-sync] Convex não configurado; sincronização ignorada.") + return false + } + console.error("[companies-sync] Falha ao sincronizar empresa no Convex", error) + throw error + } +} + +export async function removeConvexCompany(params: RemoveParams): Promise { + try { + const client = createConvexClient() + const result = await client.mutation(api.companies.removeBySlug, params) + return Boolean(result?.removed ?? true) + } catch (error) { + if (error instanceof ConvexConfigurationError) { + console.warn("[companies-sync] Convex não configurado; remoção ignorada.") + return false + } + console.error("[companies-sync] Falha ao remover empresa no Convex", error) + throw error + } +}