feat: sync convex companies and dashboard metrics
This commit is contained in:
parent
4f52114b48
commit
7a3eca9361
10 changed files with 356 additions and 19 deletions
|
|
@ -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 }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
54
scripts/prune-convex-companies.mjs
Normal file
54
scripts/prune-convex-companies.mjs
Normal file
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<SectionCards />
|
||||
<SectionCards />
|
||||
<div className="grid gap-6 px-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:px-6 lg:[&>*]:min-w-0">
|
||||
<ChartOpenedResolved />
|
||||
<div className="flex flex-col gap-6">
|
||||
<ChartOpenedResolved />
|
||||
<ChartOpenByPriority />
|
||||
</div>
|
||||
<RecentTicketsPanel />
|
||||
</div>
|
||||
<div className="px-4 lg:px-6">
|
||||
|
|
|
|||
143
src/components/charts/chart-open-priority.tsx
Normal file
143
src/components/charts/chart-open-priority.tsx
Normal file
|
|
@ -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<PriorityKey, { label: string; color: string }> = {
|
||||
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<string, number> } | 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 <Skeleton className="h-[300px] w-full" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Tickets em andamento por prioridade</CardTitle>
|
||||
<CardDescription>Distribuição de tickets iniciados e ainda abertos</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={(value) => value && setTimeRange(value)}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[640px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-6 pt-4 sm:px-6">
|
||||
{totalTickets === 0 ? (
|
||||
<div className="flex h-[260px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
||||
Sem tickets em andamento no período selecionado.
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[260px] w-full">
|
||||
<BarChart data={chartData} margin={{ top: 12, left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="priority" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" nameKey="value" labelKey="priority" />}
|
||||
/>
|
||||
<Bar dataKey="value" radius={8}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={`cell-${entry.priority}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
|
||||
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
|
||||
<CardHeader className="gap-3">
|
||||
<CardDescription>Tickets novos</CardDescription>
|
||||
<CardDescription>Tickets em atendimento</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{dashboard ? dashboard.newTickets.last24h : <Skeleton className="h-8 w-20" />}
|
||||
{inProgressSummary ? inProgressSummary.current : <Skeleton className="h-8 w-20" />}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge
|
||||
|
|
@ -109,10 +123,10 @@ export function SectionCards() {
|
|||
{trendInfo.value === null
|
||||
? "Aguardando histórico"
|
||||
: trendInfo.value >= 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"}
|
||||
</div>
|
||||
<span>Comparação com as 24h anteriores.</span>
|
||||
<span>Considera tickets iniciados (com 1ª resposta) e ainda sem resolução. Comparação com o mesmo horário das últimas 24h.</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
44
src/server/companies-sync.ts
Normal file
44
src/server/companies-sync.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue