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:
@@ -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",
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user