feat(frontend): Billing > Consumo (platform) + Sistema > Configurações — zera "em breve"
GET /billing/consumo agrega o mesmo uso bruto de /reports/consumo (tenant), só que em loop por todos os tenants — nunca dinheiro, só quantidade (dinheiro é Billing > Relatórios, já existia). GET /platform/system-config: "Sistema > Configurações" nunca teve escopo definido na especificação. Decisão desta implementação: painel somente leitura das flags de segurança/infra que já existem como variável de ambiente (DIALER_SIMULATION/ ALLOW_REAL_OUTBOUND_CALLS, ESL configurado, storage provider, NODE_ENV) — nunca editável por aqui, mudar exige editar o .env e reiniciar o serviço. Nunca expõe segredo nenhum. Com isto, todo item dos menus Platform e Tenant tem uma tela real por trás — zero "em breve" restando em nav-data.ts nos dois lados. Testado ponta a ponta: /billing/consumo batendo com os mesmos números já vistos em Relatórios > Consumo/Quotas, /platform/system-config confirmado mostrando o valor real do .env desta VM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
27
TODO.md
27
TODO.md
@@ -1657,6 +1657,33 @@ secao 96-103, 124, 169) + achado real de autorização em `/ai/models`
|
|||||||
manual) e a tela de Custos mostrando o mesmo número — tudo removido
|
manual) e a tela de Custos mostrando o mesmo número — tudo removido
|
||||||
no final
|
no final
|
||||||
|
|
||||||
|
## PHASE 46 — Platform > Billing > Consumo + Sistema > Configurações
|
||||||
|
(fecha a lista inteira de "em breve" do menu Platform, agente.md secao
|
||||||
|
169)
|
||||||
|
- [x] `GET /billing/consumo` — mesma agregação de `/reports/consumo`
|
||||||
|
(tenant), só que em loop por todos os tenants (`withTenantContext`
|
||||||
|
por tenant, mesmo padrão já usado em Quotas/ai-usage). Nunca
|
||||||
|
dinheiro, só quantidade bruta — dinheiro é Billing > Relatórios
|
||||||
|
(`BillingStatement`, já existia)
|
||||||
|
- [x] `GET /platform/system-config` — Sistema > Configurações nunca teve
|
||||||
|
escopo definido na especificação; decisão desta implementação:
|
||||||
|
painel **somente leitura** das flags de segurança/infra que já
|
||||||
|
existem como variável de ambiente (`DIALER_SIMULATION`/
|
||||||
|
`ALLOW_REAL_OUTBOUND_CALLS`, secao 186; ESL configurado; storage
|
||||||
|
provider; NODE_ENV), nunca editável por aqui — mudar exige editar
|
||||||
|
o `.env` e reiniciar o serviço. Nunca expõe segredo nenhum (senha,
|
||||||
|
chave, connection string), só o que já é público conhecimento de
|
||||||
|
quem administra a infra
|
||||||
|
- [x] Testado ponta a ponta: `/billing/consumo` batendo com os mesmos
|
||||||
|
números já vistos em Relatórios > Consumo/Quotas (Acme com 2
|
||||||
|
dias-tronco), `/platform/system-config` confirmado mostrando o
|
||||||
|
valor real do `.env` (`DIALER_SIMULATION=true`,
|
||||||
|
`ALLOW_REAL_OUTBOUND_CALLS=false`, ESL configurado)
|
||||||
|
- [x] Com isto, **todo item do menu Platform tem tela real** — zero
|
||||||
|
`{ label: "X" }` sem `href` restando em `platform-shell/nav-data.ts`
|
||||||
|
(confirmado por grep). O menu Tenant já tinha zerado essa lista na
|
||||||
|
PHASE 42
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Riscos conhecidos
|
## Riscos conhecidos
|
||||||
|
|||||||
67
apps/api/src/billing/billing-consumo.controller.ts
Normal file
67
apps/api/src/billing/billing-consumo.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Billing > Consumo" (agente.md secao 169) — a mesma agregação de
|
||||||
|
* `/reports/consumo` (tenant, mês corrente), só que em TODOS os tenants
|
||||||
|
* de uma vez. Nunca calcula valor em dinheiro (isso é o RatingEngine/
|
||||||
|
* BillingStatement — Billing > Relatórios), só a quantidade bruta dos 2
|
||||||
|
* ledgers imutáveis. Diferente de Clientes > Quotas: aqui é "quanto cada
|
||||||
|
* tenant consumiu", lá é "quanto sobra até o limite do plano".
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("billing/consumo")
|
||||||
|
export class BillingConsumoController {
|
||||||
|
@RequirePermission("billing.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver consumo 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 }, orderBy: { legalName: "asc" } });
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
tenants.map(async (tenant) => {
|
||||||
|
const [usageByMeter, aiUsageByType] = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||||
|
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usage: Record<string, number> = {};
|
||||||
|
for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0;
|
||||||
|
const aiUsage: Record<string, number> = {};
|
||||||
|
for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
legalName: tenant.legalName,
|
||||||
|
usage: {
|
||||||
|
callCount: usage["CALL_COUNT"] ?? 0,
|
||||||
|
callSeconds: usage["CALL_SECONDS"] ?? 0,
|
||||||
|
extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0,
|
||||||
|
agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0,
|
||||||
|
trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0,
|
||||||
|
recordingBytes: usage["RECORDING_BYTES"] ?? 0,
|
||||||
|
},
|
||||||
|
aiUsage: {
|
||||||
|
transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0,
|
||||||
|
analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0,
|
||||||
|
inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0,
|
||||||
|
outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { PlanVersionsController } from "./plan-versions.controller";
|
|||||||
import { SubscriptionsController } from "./subscriptions.controller";
|
import { SubscriptionsController } from "./subscriptions.controller";
|
||||||
import { BillingPeriodsController } from "./billing-periods.controller";
|
import { BillingPeriodsController } from "./billing-periods.controller";
|
||||||
import { BillingStatementsController } from "./billing-statements.controller";
|
import { BillingStatementsController } from "./billing-statements.controller";
|
||||||
|
import { BillingConsumoController } from "./billing-consumo.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [
|
||||||
@@ -14,6 +15,7 @@ import { BillingStatementsController } from "./billing-statements.controller";
|
|||||||
SubscriptionsController,
|
SubscriptionsController,
|
||||||
BillingPeriodsController,
|
BillingPeriodsController,
|
||||||
BillingStatementsController,
|
BillingStatementsController,
|
||||||
|
BillingConsumoController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
45
apps/api/src/platform/platform-system-config.controller.ts
Normal file
45
apps/api/src/platform/platform-system-config.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||||
|
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sistema > Configurações" (agente.md secao 169) — nunca teve escopo
|
||||||
|
* definido na especificação. Decisão desta implementação: painel
|
||||||
|
* read-only das flags de segurança/infra que já existem como variável de
|
||||||
|
* ambiente (`.env`, compartilhado por `apps/api` e os workers via
|
||||||
|
* systemd/docker-compose — ver `infrastructure/systemd/README.md` e
|
||||||
|
* `docker-compose.yml`), nunca editável por aqui — mudar exige editar o
|
||||||
|
* `.env` e reiniciar o serviço (documentado, não um botão de UI que
|
||||||
|
* fingiria aplicar na hora). Nunca expõe segredo nenhum (senha, chave,
|
||||||
|
* connection string) — só booleans/enums que já são público conhecimento
|
||||||
|
* de quem administra a infraestrutura.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("platform/system-config")
|
||||||
|
export class PlatformSystemConfigController {
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get()
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver a configuracao do sistema");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialerSimulation = (process.env.DIALER_SIMULATION ?? "true") === "true";
|
||||||
|
const allowRealOutboundCalls = (process.env.ALLOW_REAL_OUTBOUND_CALLS ?? "false") === "true";
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||||
|
dialerSimulation,
|
||||||
|
allowRealOutboundCalls,
|
||||||
|
// As DUAS precisam estar explicitamente ligadas (secao 186) — nunca
|
||||||
|
// basta uma pra originar PSTN de verdade.
|
||||||
|
realOutboundCallsActive: !dialerSimulation && allowRealOutboundCalls,
|
||||||
|
storageProvider: process.env.STORAGE_PROVIDER ?? "local",
|
||||||
|
eslConfigured: Boolean(process.env.ESL_HOST) && Boolean(process.env.ESL_PASSWORD),
|
||||||
|
corsOrigin: process.env.CORS_ORIGIN ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { PlatformRolesController } from "./platform-roles.controller";
|
|||||||
import { PlatformQuotasController } from "./platform-quotas.controller";
|
import { PlatformQuotasController } from "./platform-quotas.controller";
|
||||||
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
||||||
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
||||||
|
import { PlatformSystemConfigController } from "./platform-system-config.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [
|
||||||
@@ -18,6 +19,7 @@ import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
|||||||
PlatformQuotasController,
|
PlatformQuotasController,
|
||||||
PlatformFreeswitchController,
|
PlatformFreeswitchController,
|
||||||
PlatformAiUsageController,
|
PlatformAiUsageController,
|
||||||
|
PlatformSystemConfigController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/billing-consumo-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/billing-consumo-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { EmptyState } from "@/components/ui/table";
|
||||||
|
import { formatBytes, formatDuration, formatInt } from "@/lib/format";
|
||||||
|
import type { TenantConsumo } from "@/lib/billing-types";
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
|
<span className="mt-1 block font-mono text-sm tabular-nums text-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsumoView({ consumo }: { consumo: TenantConsumo[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Consumo</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Uso bruto de todos os tenants no mês corrente — mesma agregação de Relatórios > Consumo (tenant), só
|
||||||
|
que aqui em todos de uma vez. Nunca em dinheiro (isso é Billing > Relatórios).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{consumo.length === 0 ? (
|
||||||
|
<Panel>
|
||||||
|
<EmptyState title="Nenhum tenant" description="Nenhum tenant cadastrado ainda." />
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
consumo.map((c) => {
|
||||||
|
const recording = formatBytes(c.usage.recordingBytes);
|
||||||
|
return (
|
||||||
|
<Panel key={c.tenantId} className="p-5">
|
||||||
|
<PanelHeader title={c.legalName} />
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4 lg:grid-cols-8">
|
||||||
|
<Stat label="Chamadas" value={formatInt(c.usage.callCount)} />
|
||||||
|
<Stat label="Tempo em chamada" value={formatDuration(c.usage.callSeconds)} />
|
||||||
|
<Stat label="Ramais ativos/dia" value={c.usage.extensionActiveDays} />
|
||||||
|
<Stat label="Agentes ativos/dia" value={c.usage.agentActiveDays} />
|
||||||
|
<Stat label="Troncos ativos/dia" value={c.usage.trunkActiveDays} />
|
||||||
|
<Stat label="Gravações" value={`${recording.value}${recording.unit}`} />
|
||||||
|
<Stat label="Transcrição IA" value={formatDuration(c.aiUsage.transcriptionSeconds)} />
|
||||||
|
<Stat label="Tokens IA (in/out)" value={`${formatInt(c.aiUsage.inputTokens)} / ${formatInt(c.aiUsage.outputTokens)}`} />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/platform/billing/consumo/page.tsx
Normal file
10
apps/frontend/src/app/platform/billing/consumo/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { TenantConsumo } from "@/lib/billing-types";
|
||||||
|
import { ConsumoView } from "./consumo-view";
|
||||||
|
|
||||||
|
export default async function BillingConsumoPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const consumo = await apiFetch<TenantConsumo[]>("/billing/consumo", session.accessToken);
|
||||||
|
return <ConsumoView consumo={consumo} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { CircleCheck, CircleX } from "lucide-react";
|
||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { Pill } from "@/components/ui/pill";
|
||||||
|
import type { SystemConfig } from "@/lib/platform-types";
|
||||||
|
|
||||||
|
function FlagRow({ label, on, description }: { label: string; on: boolean; description: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
{on ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 text-status-green">
|
||||||
|
<CircleCheck className="h-4 w-4" aria-hidden /> Ligado
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground">
|
||||||
|
<CircleX className="h-4 w-4" aria-hidden /> Desligado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfiguracoesView({ config }: { config: SystemConfig }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Configurações do sistema</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Somente leitura — estas flags vêm do <code className="font-mono text-xs">.env</code> compartilhado por
|
||||||
|
todos os serviços (agente.md secao 186). Mudar exige editar o arquivo e reiniciar o serviço, nunca um botão
|
||||||
|
aqui que fingiria aplicar na hora.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="p-5">
|
||||||
|
<PanelHeader title="Discador — segurança de chamada real" />
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<Pill tone={config.realOutboundCallsActive ? "accent" : "neutral"}>
|
||||||
|
{config.realOutboundCallsActive ? "Chamadas PSTN reais ATIVAS" : "Nenhuma chamada PSTN real é feita"}
|
||||||
|
</Pill>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
As duas flags abaixo precisam estar explicitamente na posição de risco ao mesmo tempo pra originar uma
|
||||||
|
chamada real — nunca ativado por padrão.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<FlagRow
|
||||||
|
label="DIALER_SIMULATION"
|
||||||
|
on={config.dialerSimulation}
|
||||||
|
description="Ligado = resultados de chamada (atendida/ocupado/não atende) são sorteados em software, nenhuma chamada PSTN sai de verdade."
|
||||||
|
/>
|
||||||
|
<FlagRow
|
||||||
|
label="ALLOW_REAL_OUTBOUND_CALLS"
|
||||||
|
on={config.allowRealOutboundCalls}
|
||||||
|
description="Precisa estar ligado E DIALER_SIMULATION desligado pra originar uma chamada PSTN de verdade."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title="Infraestrutura" />
|
||||||
|
<FlagRow
|
||||||
|
label="Event Socket do FreeSWITCH (ESL) configurado"
|
||||||
|
on={config.eslConfigured}
|
||||||
|
description="Host e senha presentes no ambiente (não confirma alcançável — ver Infraestrutura > Saúde)."
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Storage de gravações</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">Onde os arquivos de gravação são salvos.</p>
|
||||||
|
</div>
|
||||||
|
<Pill>{config.storageProvider}</Pill>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Ambiente (NODE_ENV)</p>
|
||||||
|
</div>
|
||||||
|
<Pill tone={config.nodeEnv === "production" ? "accent" : "neutral"}>{config.nodeEnv}</Pill>
|
||||||
|
</div>
|
||||||
|
{config.corsOrigin && (
|
||||||
|
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">CORS_ORIGIN</p>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{config.corsOrigin}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { SystemConfig } from "@/lib/platform-types";
|
||||||
|
import { ConfiguracoesView } from "./configuracoes-view";
|
||||||
|
|
||||||
|
export default async function SistemaConfiguracoesPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const config = await apiFetch<SystemConfig>("/platform/system-config", session.accessToken);
|
||||||
|
return <ConfiguracoesView config={config} />;
|
||||||
|
}
|
||||||
@@ -46,7 +46,11 @@ export const PLATFORM_NAV: NavSection[] = [
|
|||||||
label: "Billing",
|
label: "Billing",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
children: [
|
children: [
|
||||||
{ label: "Consumo" },
|
{
|
||||||
|
label: "Consumo",
|
||||||
|
href: "/platform/billing/consumo",
|
||||||
|
description: "Uso bruto de todos os tenants no mês corrente",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Tarifas",
|
label: "Tarifas",
|
||||||
href: "/platform/billing/tarifas",
|
href: "/platform/billing/tarifas",
|
||||||
@@ -135,7 +139,11 @@ export const PLATFORM_NAV: NavSection[] = [
|
|||||||
href: "/platform/sistema/auditoria",
|
href: "/platform/sistema/auditoria",
|
||||||
description: "Log de eventos de todos os tenants",
|
description: "Log de eventos de todos os tenants",
|
||||||
},
|
},
|
||||||
{ label: "Configurações" },
|
{
|
||||||
|
label: "Configurações",
|
||||||
|
href: "/platform/sistema/configuracoes",
|
||||||
|
description: "Flags de segurança/infra do .env — somente leitura",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -93,6 +93,25 @@ export interface BillingStatement {
|
|||||||
items?: BillingStatementItem[];
|
items?: BillingStatementItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TenantConsumo {
|
||||||
|
tenantId: string;
|
||||||
|
legalName: string;
|
||||||
|
usage: {
|
||||||
|
callCount: number;
|
||||||
|
callSeconds: number;
|
||||||
|
extensionActiveDays: number;
|
||||||
|
agentActiveDays: number;
|
||||||
|
trunkActiveDays: number;
|
||||||
|
recordingBytes: number;
|
||||||
|
};
|
||||||
|
aiUsage: {
|
||||||
|
transcriptionSeconds: number;
|
||||||
|
analysisRequests: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlanVersion {
|
export interface PlanVersion {
|
||||||
id: string;
|
id: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ export interface FreeswitchNodes {
|
|||||||
gateways: unknown;
|
gateways: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemConfig {
|
||||||
|
nodeEnv: string;
|
||||||
|
dialerSimulation: boolean;
|
||||||
|
allowRealOutboundCalls: boolean;
|
||||||
|
realOutboundCallsActive: boolean;
|
||||||
|
storageProvider: string;
|
||||||
|
eslConfigured: boolean;
|
||||||
|
corsOrigin: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TenantAiUsage {
|
export interface TenantAiUsage {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
legalName: string;
|
legalName: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user