diff --git a/TODO.md b/TODO.md index 204a606..30fe467 100644 --- a/TODO.md +++ b/TODO.md @@ -1556,6 +1556,41 @@ Usuários (agente.md secao 169) Acme (2 dias-tronco já ledgerados, resto zerado — nenhuma chamada rodou ainda neste tenant), tela renderizando os mesmos números +## PHASE 43 — Platform > Clientes > Assinaturas/Quotas (agente.md secao +126-127, 169) + achado real no dashboard de plataforma +- [x] achado real (não relacionado às telas novas, achado revisando + `PlatformOverviewController` antes de escrever a versão platform-wide + de `/reports/consumo`): `aiUsageThisMonth` e `recordingStorageBytes` + no dashboard "Visão Geral" SEMPRE devolviam zero/vazio, não importa + quanto uso real existisse — `ai_usage_records`/`recordings` têm + FORCE RLS (secao 32) e o código rodava `prisma.aIUsageRecord.groupBy`/ + `prisma.recording.aggregate` direto, sem nenhum `app.current_tenant_id` + setado, então a policy nega tudo silenciosamente (0 linhas, sem + erro). Mesma classe de bug já corrigida 2x antes nesta sessão + (`TenantsController.list`, `UsersController.remove`). Corrigido com + o mesmo padrão (loop `withTenantContext` por tenant, soma no + código) — confirmado inserindo um `AIUsageRecord` de teste direto no + Postgres, vendo o número aparecer no endpoint, e removendo o teste + depois +- [x] `GET /billing/subscriptions` e `POST /billing/plan-versions` já + existiam desde a fase Billing (PHASE 22) sem nenhuma tela — só + faltava o frontend. Tela `/platform/clientes/assinaturas`: escolher + tenant, ver histórico de assinaturas (versão/preço/vigência/ciclo/ + status), criar uma nova assinatura reaproveitando uma versão de + preço existente OU criando uma nova na mesma ação (dois preços + nunca sobrescritos, sempre uma versão nova) +- [x] `GET /platform/quotas` (novo) — uso vs. limite do plano em TODOS os + tenants (ramais/agentes/troncos/filas/campanhas/chamadas-mês/ + armazenamento), pra achar quem está perto de estourar sem abrir + tenant por tenant. Tela `/platform/clientes/quotas` destaca em + amarelo (>=80%) e vermelho (>=100%), nunca conta "sem limite" como + estourado +- [x] Testado ponta a ponta: fluxo completo de criar assinatura via UI + pro tenant Beta Corp (nova versão de preço R$499,90 + assinatura + com ciclo dia 15, confirmado na tela e no banco), Quotas mostrando + os números reais e corretos dos dois tenants de teste (Acme com + 50% de troncos usados, Beta Corp com zero) + --- ## Riscos conhecidos diff --git a/apps/api/src/platform/platform-overview.controller.ts b/apps/api/src/platform/platform-overview.controller.ts index c9fd4ed..8332cca 100644 --- a/apps/api/src/platform/platform-overview.controller.ts +++ b/apps/api/src/platform/platform-overview.controller.ts @@ -1,5 +1,5 @@ import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common"; -import { getPrismaClient } from "@b2bcall/database"; +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { PermissionGuard } from "../common/guards/permission.guard"; @@ -28,32 +28,46 @@ export class PlatformOverviewController { todayStart.setUTCHours(0, 0, 0, 0); const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1)); - const [ - tenantsActive, - tenantsTotal, - extensionsTotal, - agentsTotal, - callsCurrent, - callsToday, - cpsCapacity, - aiUsageThisMonth, - recordingBytesAgg, - ] = await Promise.all([ - prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }), - prisma.tenant.count({ where: { deletedAt: null } }), - prisma.extension.count({ where: { deletedAt: null } }), - prisma.agent.count({ where: { deletedAt: null } }), - prisma.call.count({ where: { endAt: null } }), - prisma.call.count({ where: { createdAt: { gte: todayStart } } }), - prisma.plan.aggregate({ _sum: { maxCps: true } }), - prisma.aIUsageRecord.groupBy({ - by: ["type"], - where: { occurredAt: { gte: monthStart } }, - _sum: { quantity: true }, - }), - prisma.recording.aggregate({ _sum: { sizeBytes: true } }), + const [tenantsActive, tenantsTotal, extensionsTotal, agentsTotal, callsCurrent, callsToday, cpsCapacity] = + await Promise.all([ + prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }), + prisma.tenant.count({ where: { deletedAt: null } }), + prisma.extension.count({ where: { deletedAt: null } }), + prisma.agent.count({ where: { deletedAt: null } }), + prisma.call.count({ where: { endAt: null } }), + prisma.call.count({ where: { createdAt: { gte: todayStart } } }), + prisma.plan.aggregate({ _sum: { maxCps: true } }), + ]); + + // `ai_usage_records`/`recordings` têm FORCE RLS (secao 32) — as duas + // agregações acima (achado real, corrigido aqui) rodavam direto no + // Prisma sem `app.current_tenant_id` nenhum setado, então SEMPRE + // devolviam 0 linhas (zero silencioso, sem erro nenhum), nunca o + // número real, não importa quanto uso existisse nos tenants. Mesma + // classe de bug já corrigida em `TenantsController.list` — só um + // loop `withTenantContext` por tenant enxerga as linhas de verdade. + const activeTenantIds = await prisma.tenant.findMany({ where: { deletedAt: null }, select: { id: true } }); + const [aiUsagePerTenant, recordingBytesPerTenant] = await Promise.all([ + Promise.all( + activeTenantIds.map((t) => + withTenantContext(prisma, t.id, (tx) => + tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: t.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }), + ), + ), + ), + Promise.all( + activeTenantIds.map((t) => + withTenantContext(prisma, t.id, (tx) => tx.recording.aggregate({ where: { tenantId: t.id }, _sum: { sizeBytes: true } })), + ), + ), ]); + const aiUsageThisMonth: Record = {}; + for (const rows of aiUsagePerTenant) { + for (const row of rows) aiUsageThisMonth[row.type] = (aiUsageThisMonth[row.type] ?? 0) + (row._sum.quantity ?? 0); + } + const recordingStorageBytes = recordingBytesPerTenant.reduce((sum, agg) => sum + Number(agg._sum.sizeBytes ?? 0n), 0); + return { tenantsActive, tenantsTotal, @@ -70,10 +84,8 @@ export class PlatformOverviewController { // token bucket do Redis do predictive-dialer, apps/api nao le de // la ainda). cpsCapacityConfigured: cpsCapacity._sum.maxCps ?? null, - aiUsageThisMonth: Object.fromEntries( - aiUsageThisMonth.map((row) => [row.type, row._sum.quantity ?? 0]), - ), - recordingStorageBytes: Number(recordingBytesAgg._sum.sizeBytes ?? 0n), + aiUsageThisMonth, + recordingStorageBytes, // Precisa de BillingPeriod/BillingStatement fechados de verdade // (fase Billing, em construcao) — nenhum periodo foi fechado ainda // nesta lab, entao nao ha numero real pra mostrar. null e' honesto, diff --git a/apps/api/src/platform/platform-quotas.controller.ts b/apps/api/src/platform/platform-quotas.controller.ts new file mode 100644 index 0000000..51ada26 --- /dev/null +++ b/apps/api/src/platform/platform-quotas.controller.ts @@ -0,0 +1,73 @@ +import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common"; +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; +import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +/** + * "Clientes > Quotas" (agente.md secao 169) — uso vs. limite do plano em + * TODOS os tenants, pra platform admin achar quem está perto de estourar + * sem precisar abrir um por um. Mesmo cálculo de `/reports/consumo` + * (tenant), só que looping por tenant (RLS, secao 32) em vez de um só. + */ +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("platform/quotas") +export class PlatformQuotasController { + @RequirePermission("tenants.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims): Promise[]> { + if (!(await isPlatformUser(user.sub))) { + throw new ForbiddenException("So' um usuario com role de plataforma pode ver quotas de todos os tenants"); + } + const prisma = getPrismaClient(); + const now = new Date(); + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + + const tenants = await prisma.tenant.findMany({ + where: { deletedAt: null }, + include: { plan: true }, + orderBy: { legalName: "asc" }, + }); + + return Promise.all( + tenants.map(async (tenant) => { + const [callCountAgg, recordingAgg, extensionCount, agentCount, trunkCount, queueCount, campaignCount] = + await withTenantContext(prisma, tenant.id, (tx) => + Promise.all([ + tx.usageEvent.aggregate({ where: { tenantId: tenant.id, meter: "CALL_COUNT", occurredAt: { gte: monthStart } }, _sum: { quantity: true } }), + tx.recording.aggregate({ where: { tenantId: tenant.id }, _sum: { sizeBytes: true } }), + tx.extension.count({ where: { tenantId: tenant.id, deletedAt: null } }), + tx.agent.count({ where: { tenantId: tenant.id, deletedAt: null } }), + tx.trunk.count({ where: { tenantId: tenant.id, deletedAt: null } }), + tx.queue.count({ where: { tenantId: tenant.id, deletedAt: null } }), + tx.campaign.count({ where: { tenantId: tenant.id, deletedAt: null } }), + ]), + ); + + const callCount = callCountAgg._sum.quantity ?? 0; + const recordingBytes = Number(recordingAgg._sum.sizeBytes ?? 0n); + const recordingGb = recordingBytes / 1024 ** 3; + + const ratio = (used: number, max: number | null) => (max ? used / max : null); + + return { + tenantId: tenant.id, + legalName: tenant.legalName, + planName: tenant.plan.name, + status: tenant.status, + items: [ + { key: "extensions", label: "Ramais", used: extensionCount, max: tenant.plan.maxExtensions, ratio: ratio(extensionCount, tenant.plan.maxExtensions) }, + { key: "agents", label: "Agentes", used: agentCount, max: tenant.plan.maxAgents, ratio: ratio(agentCount, tenant.plan.maxAgents) }, + { key: "trunks", label: "Troncos", used: trunkCount, max: tenant.plan.maxTrunks, ratio: ratio(trunkCount, tenant.plan.maxTrunks) }, + { key: "queues", label: "Filas", used: queueCount, max: tenant.plan.maxQueues, ratio: ratio(queueCount, tenant.plan.maxQueues) }, + { key: "campaigns", label: "Campanhas", used: campaignCount, max: tenant.plan.maxCampaigns, ratio: ratio(campaignCount, tenant.plan.maxCampaigns) }, + { key: "monthlyCalls", label: "Chamadas/mês", used: callCount, max: tenant.plan.maxMonthlyCalls, ratio: ratio(callCount, tenant.plan.maxMonthlyCalls) }, + { key: "recordingGb", label: "Armazenamento (GB)", used: Number(recordingGb.toFixed(3)), max: tenant.plan.maxRecordingStorageGb, ratio: ratio(recordingGb, tenant.plan.maxRecordingStorageGb) }, + ], + }; + }), + ); + } +} diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts index b6a29dd..60f4a89 100644 --- a/apps/api/src/platform/platform.module.ts +++ b/apps/api/src/platform/platform.module.ts @@ -4,6 +4,7 @@ import { PlatformUsersController } from "./platform-users.controller"; import { PlatformAuditController } from "./platform-audit.controller"; import { PlatformHealthController } from "./platform-health.controller"; import { PlatformRolesController } from "./platform-roles.controller"; +import { PlatformQuotasController } from "./platform-quotas.controller"; @Module({ controllers: [ @@ -12,6 +13,7 @@ import { PlatformRolesController } from "./platform-roles.controller"; PlatformAuditController, PlatformHealthController, PlatformRolesController, + PlatformQuotasController, ], }) export class PlatformModule {} diff --git a/apps/frontend/.impeccable/review/assinaturas-created-desktop.png b/apps/frontend/.impeccable/review/assinaturas-created-desktop.png new file mode 100644 index 0000000..002885a Binary files /dev/null and b/apps/frontend/.impeccable/review/assinaturas-created-desktop.png differ diff --git a/apps/frontend/.impeccable/review/assinaturas-empty-desktop.png b/apps/frontend/.impeccable/review/assinaturas-empty-desktop.png new file mode 100644 index 0000000..4d69d5d Binary files /dev/null and b/apps/frontend/.impeccable/review/assinaturas-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/assinaturas-form-desktop.png b/apps/frontend/.impeccable/review/assinaturas-form-desktop.png new file mode 100644 index 0000000..bffbf65 Binary files /dev/null and b/apps/frontend/.impeccable/review/assinaturas-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/assinaturas-with-tenant-desktop.png b/apps/frontend/.impeccable/review/assinaturas-with-tenant-desktop.png new file mode 100644 index 0000000..3d24dee Binary files /dev/null and b/apps/frontend/.impeccable/review/assinaturas-with-tenant-desktop.png differ diff --git a/apps/frontend/.impeccable/review/quotas-desktop.png b/apps/frontend/.impeccable/review/quotas-desktop.png new file mode 100644 index 0000000..3469165 Binary files /dev/null and b/apps/frontend/.impeccable/review/quotas-desktop.png differ diff --git a/apps/frontend/src/app/platform/clientes/assinaturas/actions.ts b/apps/frontend/src/app/platform/clientes/assinaturas/actions.ts new file mode 100644 index 0000000..bd647d3 --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/assinaturas/actions.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { PlanVersion, TenantSubscription } from "@/lib/billing-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateSubscriptionInput { + tenantId: string; + planId: string; + billingCycleAnchor: number; + currency: string; + // Ou usa uma versão existente, ou cria uma nova (mutuamente exclusivo — a + // tela só mostra um dos dois blocos de campo por vez). + planVersionId?: string; + newVersion?: { basePrice: number; effectiveFrom: string }; +} + +export async function createSubscription(input: CreateSubscriptionInput): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + let planVersionId = input.planVersionId; + if (!planVersionId && input.newVersion) { + const planVersion = await apiFetch("/billing/plan-versions", session.accessToken, { + method: "POST", + body: JSON.stringify({ + planId: input.planId, + basePrice: input.newVersion.basePrice, + currency: input.currency, + effectiveFrom: input.newVersion.effectiveFrom, + }), + }); + planVersionId = planVersion.id; + } + if (!planVersionId) return { ok: false, error: "Escolha uma versão de preço ou crie uma nova." }; + + await apiFetch("/billing/subscriptions", session.accessToken, { + method: "POST", + body: JSON.stringify({ + tenantId: input.tenantId, + planVersionId, + billingCycleAnchor: input.billingCycleAnchor, + currency: input.currency, + }), + }); + revalidatePath("/platform/clientes/assinaturas"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/platform/clientes/assinaturas/assinaturas-view.tsx b/apps/frontend/src/app/platform/clientes/assinaturas/assinaturas-view.tsx new file mode 100644 index 0000000..72d2e98 --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/assinaturas/assinaturas-view.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, Select, FieldLabel } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatCurrency, formatDate } from "@/lib/format"; +import type { Tenant } from "@/lib/platform-types"; +import { TENANT_SUBSCRIPTION_STATUS_LABELS, type PlanVersion, type TenantSubscription } from "@/lib/billing-types"; +import { createSubscription } from "./actions"; + +const STATUS_TONE: Record = { + ACTIVE: "accent", + TRIALING: "neutral", + PAST_DUE: "neutral", + CANCELED: "neutral", +}; + +export function AssinaturasView({ + tenants, + selectedTenant, + subscriptions, + planVersions, +}: { + tenants: Tenant[]; + selectedTenant: Tenant | null; + subscriptions: TenantSubscription[]; + planVersions: PlanVersion[]; +}) { + const router = useRouter(); + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

Assinaturas

+

+ Qual versão de preço (agente.md secao 126-127) um tenant assinou, e em que dia do mês fecha o ciclo de + billing dele. Histórico, nunca sobrescrito — trocar de preço cria uma linha nova. +

+
+ {selectedTenant && ( + + )} +
+ + + Tenant + + + + {!selectedTenant ? ( + + + + ) : ( + <> + {showForm && ( + setShowForm(false)} + /> + )} + + + + {subscriptions.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {subscriptions.map((s) => ( + + + + + + + + + ))} + +
VersãoPreço baseVigente desdeInício da assinaturaCiclo (dia)Status
v{s.planVersion.version}{formatCurrency(s.planVersion.basePrice, s.currency)}{formatDate(s.planVersion.effectiveFrom)}{formatDate(s.startedAt)}{s.billingCycleAnchor} + {TENANT_SUBSCRIPTION_STATUS_LABELS[s.status] ?? s.status} +
+ )} +
+ + )} +
+ ); +} + +function NewSubscriptionForm({ + tenantId, + planId, + planName, + planVersions, + onDone, +}: { + tenantId: string; + planId: string; + planName: string; + planVersions: PlanVersion[]; + onDone: () => void; +}) { + const router = useRouter(); + const [mode, setMode] = useState<"existing" | "new">(planVersions.length > 0 ? "existing" : "new"); + const [planVersionId, setPlanVersionId] = useState(planVersions[0]?.id ?? ""); + const [basePrice, setBasePrice] = useState(""); + const [effectiveFrom, setEffectiveFrom] = useState(new Date().toISOString().slice(0, 10)); + const [billingCycleAnchor, setBillingCycleAnchor] = useState("1"); + const [currency, setCurrency] = useState("BRL"); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const anchor = Number(billingCycleAnchor); + if (!Number.isInteger(anchor) || anchor < 1 || anchor > 28) { + setError("Dia do ciclo precisa ser um número entre 1 e 28."); + return; + } + if (mode === "new" && (!basePrice || Number(basePrice) < 0)) { + setError("Informe o preço base da nova versão."); + return; + } + startTransition(async () => { + const result = await createSubscription({ + tenantId, + planId, + billingCycleAnchor: anchor, + currency, + ...(mode === "existing" ? { planVersionId } : { newVersion: { basePrice: Number(basePrice), effectiveFrom } }), + }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + router.refresh(); + }); + } + + return ( + +
+

+ Plano deste tenant: {planName} — a versão de preço + precisa pertencer a este plano. +

+ +
+ + +
+ + {mode === "existing" ? ( + planVersions.length === 0 ? ( +

Nenhuma versão de preço existe ainda pra este plano — crie uma nova.

+ ) : ( +
+ Versão + +
+ ) + ) : ( +
+
+ Preço base + setBasePrice(e.target.value)} disabled={pending} /> +
+
+ Vigente a partir de + setEffectiveFrom(e.target.value)} disabled={pending} /> +
+
+ )} + +
+
+ Dia do ciclo de billing (1-28) + setBillingCycleAnchor(e.target.value)} disabled={pending} /> +
+
+ Moeda + setCurrency(e.target.value.toUpperCase())} maxLength={3} disabled={pending} className="font-mono" /> +
+
+ + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/app/platform/clientes/assinaturas/page.tsx b/apps/frontend/src/app/platform/clientes/assinaturas/page.tsx new file mode 100644 index 0000000..3c192d1 --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/assinaturas/page.tsx @@ -0,0 +1,31 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Tenant } from "@/lib/platform-types"; +import type { PlanVersion, TenantSubscription } from "@/lib/billing-types"; +import { AssinaturasView } from "./assinaturas-view"; + +export default async function AssinaturasPage({ searchParams }: { searchParams: Promise<{ tenantId?: string }> }) { + const { tenantId } = await searchParams; + const session = await requireSession(); + + const tenants = await apiFetch("/tenants", session.accessToken); + const selectedTenant = tenantId ? (tenants.find((t) => t.id === tenantId) ?? null) : null; + + const [subscriptions, planVersions] = tenantId + ? await Promise.all([ + apiFetch(`/billing/subscriptions/by-tenant/${tenantId}`, session.accessToken), + selectedTenant + ? apiFetch(`/billing/plan-versions/by-plan/${selectedTenant.planId}`, session.accessToken) + : Promise.resolve([]), + ]) + : [[], []]; + + return ( + + ); +} diff --git a/apps/frontend/src/app/platform/clientes/quotas/page.tsx b/apps/frontend/src/app/platform/clientes/quotas/page.tsx new file mode 100644 index 0000000..9377488 --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/quotas/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { TenantQuotas } from "@/lib/platform-types"; +import { QuotasView } from "./quotas-view"; + +export default async function QuotasPage() { + const session = await requireSession(); + const quotas = await apiFetch("/platform/quotas", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/clientes/quotas/quotas-view.tsx b/apps/frontend/src/app/platform/clientes/quotas/quotas-view.tsx new file mode 100644 index 0000000..ddaf47e --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/quotas/quotas-view.tsx @@ -0,0 +1,65 @@ +import { cn } from "@/lib/utils"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState } from "@/components/ui/table"; +import { formatPercent } from "@/lib/format"; +import { TENANT_STATUS_LABELS, type TenantQuotas } from "@/lib/platform-types"; + +function ratioColor(ratio: number | null): string { + if (ratio == null) return "text-muted-foreground"; + if (ratio >= 1) return "text-status-red"; + if (ratio >= 0.8) return "text-status-yellow"; + return "text-foreground"; +} + +export function QuotasView({ quotas }: { quotas: TenantQuotas[] }) { + const nearLimit = quotas.filter((q) => q.items.some((i) => i.ratio != null && i.ratio >= 0.8)); + + return ( +
+
+

Quotas

+

+ Uso vs. limite do plano em todos os tenants (agente.md secao 169) — sem limite no plano ({"—"}) nunca + conta como "estourado". Amarelo a partir de 80% do limite, vermelho a partir de 100%. +

+
+ + {nearLimit.length > 0 && ( + +

+ {nearLimit.length} tenant(s) com pelo menos um item em 80% ou mais do + limite: {nearLimit.map((t) => t.legalName).join(", ")} +

+
+ )} + + {quotas.length === 0 ? ( + + + + ) : ( + quotas.map((tenant) => ( + +
+ + {TENANT_STATUS_LABELS[tenant.status] ?? tenant.status} +
+
+ {tenant.items.map((item) => ( +
+ {item.label} + + {item.used} + {item.max != null && / {item.max}} + {item.ratio != null && ({formatPercent(item.ratio)})} + +
+ ))} +
+
+ )) + )} +
+ ); +} diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts index 54ac947..3c8082b 100644 --- a/apps/frontend/src/components/platform-shell/nav-data.ts +++ b/apps/frontend/src/components/platform-shell/nav-data.ts @@ -30,8 +30,16 @@ export const PLATFORM_NAV: NavSection[] = [ href: "/platform/clientes/planos", description: "Catálogo de limites e recursos por plano", }, - { label: "Assinaturas" }, - { label: "Quotas" }, + { + label: "Assinaturas", + href: "/platform/clientes/assinaturas", + description: "Versão de preço assinada e ciclo de billing por tenant", + }, + { + label: "Quotas", + href: "/platform/clientes/quotas", + description: "Uso vs. limite do plano — todos os tenants, quem está perto do limite", + }, ], }, { diff --git a/apps/frontend/src/lib/billing-types.ts b/apps/frontend/src/lib/billing-types.ts index 8eeb2ec..643d6f5 100644 --- a/apps/frontend/src/lib/billing-types.ts +++ b/apps/frontend/src/lib/billing-types.ts @@ -92,3 +92,33 @@ export interface BillingStatement { billingPeriod: BillingPeriod; items?: BillingStatementItem[]; } + +export interface PlanVersion { + id: string; + planId: string; + version: number; + basePrice: number; + currency: string; + effectiveFrom: string; + effectiveUntil: string | null; + createdAt: string; +} + +export const TENANT_SUBSCRIPTION_STATUS_LABELS: Record = { + TRIALING: "Em trial", + ACTIVE: "Ativa", + PAST_DUE: "Inadimplente", + CANCELED: "Cancelada", +}; + +export interface TenantSubscription { + id: string; + tenantId: string; + planVersionId: string; + status: string; + startedAt: string; + endsAt: string | null; + billingCycleAnchor: number; + currency: string; + planVersion: PlanVersion; +} diff --git a/apps/frontend/src/lib/platform-types.ts b/apps/frontend/src/lib/platform-types.ts index c9fe453..e02c32c 100644 --- a/apps/frontend/src/lib/platform-types.ts +++ b/apps/frontend/src/lib/platform-types.ts @@ -83,6 +83,22 @@ export interface PlatformHealth { checkedAt: string; } +export interface QuotaItem { + key: string; + label: string; + used: number; + max: number | null; + ratio: number | null; +} + +export interface TenantQuotas { + tenantId: string; + legalName: string; + planName: string; + status: string; + items: QuotaItem[]; +} + export const PLAN_LIMIT_FIELDS: { key: keyof Plan; label: string }[] = [ { key: "maxExtensions", label: "Ramais" }, { key: "maxAgents", label: "Agentes" },