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:
2026-08-30 09:01:10 -03:00
parent cc80310cb5
commit fcf334c7c0
14 changed files with 347 additions and 2 deletions

View File

@@ -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 &gt; Consumo (tenant),
que aqui em todos de uma vez. Nunca em dinheiro (isso é Billing &gt; 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>
);
}

View 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} />;
}

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -46,7 +46,11 @@ export const PLATFORM_NAV: NavSection[] = [
label: "Billing",
icon: CreditCard,
children: [
{ label: "Consumo" },
{
label: "Consumo",
href: "/platform/billing/consumo",
description: "Uso bruto de todos os tenants no mês corrente",
},
{
label: "Tarifas",
href: "/platform/billing/tarifas",
@@ -135,7 +139,11 @@ export const PLATFORM_NAV: NavSection[] = [
href: "/platform/sistema/auditoria",
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",
},
],
},
];

View File

@@ -93,6 +93,25 @@ export interface BillingStatement {
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 {
id: string;
planId: string;

View File

@@ -101,6 +101,16 @@ export interface FreeswitchNodes {
gateways: unknown;
}
export interface SystemConfig {
nodeEnv: string;
dialerSimulation: boolean;
allowRealOutboundCalls: boolean;
realOutboundCallsActive: boolean;
storageProvider: string;
eslConfigured: boolean;
corsOrigin: string | null;
}
export interface TenantAiUsage {
tenantId: string;
legalName: string;