feat(frontend): Relatórios > Consumo (tenant)

GET /reports/consumo agrega os 2 ledgers imutáveis de uso (UsageEvent +
AIUsageRecord — os mesmos que o RatingEngine usa pra faturar) por meter/tipo no mês
corrente: chamadas, minutos, dias ativos, armazenamento de gravação, tokens de IA.
Nunca calcula valor em dinheiro (isso é billing/RatingEngine, platform-only) —
decisão deliberada pra não duplicar essa lógica fora dele. Devolve também os limites
do plano (maxMonthlyCalls/maxRecordingStorageGb) pra comparação.

Tela /app/relatorios/consumo reaproveita o InstrumentTile do dashboard, zeros
honestos em vez de esconder seção. Testado ponta a ponta contra o tenant Acme real
(2 dias-tronco já ledgerados aparecem certos, resto zerado por não ter chamada
rodada ainda).

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 08:22:34 -03:00
parent 2fb5010283
commit de0b632fc9
7 changed files with 196 additions and 1 deletions

View File

@@ -0,0 +1,78 @@
import { Panel, PanelHeader } from "@/components/ui/panel";
import { InstrumentTile } from "@/components/ui/instrument-tile";
import { formatBytes, formatDate, formatDuration, formatPercent } from "@/lib/format";
import type { ConsumoReport } from "@/lib/report-types";
export function ConsumoView({ report }: { report: ConsumoReport }) {
const recording = formatBytes(report.usage.recordingBytes);
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 deste tenant no período mesmos 2 ledgers imutáveis que viram fatura depois (agente.md secao
131-132), sem nenhum valor em dinheiro aqui (isso é Billing, exclusivo de plataforma). Período:{" "}
{formatDate(report.period.from)} a {formatDate(report.period.to)} (mês corrente).
</p>
</div>
<Panel className="p-5">
<PanelHeader title="Telefonia" description="Chamadas, minutos e dias ativos" />
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<InstrumentTile label="Chamadas" value={report.usage.callCount} />
<InstrumentTile label="Tempo em chamada" value={formatDuration(report.usage.callSeconds)} />
<InstrumentTile label="Ramais ativos/dia" value={report.usage.extensionActiveDays} unit="dia-ramal" />
<InstrumentTile label="Agentes ativos/dia" value={report.usage.agentActiveDays} unit="dia-agente" />
<InstrumentTile label="Troncos ativos/dia" value={report.usage.trunkActiveDays} unit="dia-tronco" />
</div>
</Panel>
<Panel className="p-5">
<PanelHeader title="Armazenamento" description="Gravações de chamada" />
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3">
<InstrumentTile label="Gravações" value={recording.value} unit={recording.unit} />
{report.limits?.maxRecordingStorageGb != null && (
<InstrumentTile
label="Limite do plano"
value={report.limits.maxRecordingStorageGb}
unit="GB"
suffix={
report.limits.recordingStorageUsedRatio != null
? `${formatPercent(report.limits.recordingStorageUsedRatio)} usado`
: undefined
}
/>
)}
</div>
</Panel>
<Panel className="p-5">
<PanelHeader title="IA" description="Transcrição, análise e tokens — zerado se o tenant não usa IA" />
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4">
<InstrumentTile label="Transcrição" value={formatDuration(report.aiUsage.transcriptionSeconds)} />
<InstrumentTile label="Análises" value={report.aiUsage.analysisRequests} />
<InstrumentTile label="Tokens de entrada" value={report.aiUsage.inputTokens} />
<InstrumentTile label="Tokens de saída" value={report.aiUsage.outputTokens} />
</div>
</Panel>
{report.limits?.maxMonthlyCalls != null && (
<Panel className="p-5">
<PanelHeader title="Limite do plano — chamadas/mês" />
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3">
<InstrumentTile
label="Chamadas usadas"
value={report.usage.callCount}
suffix={`de ${report.limits.maxMonthlyCalls}`}
/>
<InstrumentTile
label="% do limite"
value={report.limits.callCountUsedRatio != null ? formatPercent(report.limits.callCountUsedRatio) : null}
/>
</div>
</Panel>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { ConsumoReport } from "@/lib/report-types";
import { ConsumoView } from "./consumo-view";
export default async function ConsumoPage() {
const session = await requireSession();
const report = await apiFetch<ConsumoReport>("/reports/consumo", session.accessToken);
return <ConsumoView report={report} />;
}

View File

@@ -163,7 +163,11 @@ export const TENANT_NAV: NavSection[] = [
href: "/app/relatorios/campanhas",
description: "Leads, tentativas, taxas de atendimento e contato",
},
{ label: "Consumo" },
{
label: "Consumo",
href: "/app/relatorios/consumo",
description: "Uso bruto do mês — minutos, dias ativos, armazenamento, tokens de IA",
},
],
},
{

View File

@@ -77,3 +77,27 @@ export interface AIDashboardReport {
topAgents: { agentId: string; name: string; avgScore: number; calls: number }[];
bottomAgents: { agentId: string; name: string; avgScore: number; calls: number }[];
}
export interface ConsumoReport {
period: { from: string; to: string };
usage: {
callCount: number;
callSeconds: number;
extensionActiveDays: number;
agentActiveDays: number;
trunkActiveDays: number;
recordingBytes: number;
};
aiUsage: {
transcriptionSeconds: number;
analysisRequests: number;
inputTokens: number;
outputTokens: number;
};
limits: {
maxMonthlyCalls: number | null;
maxRecordingStorageGb: number | null;
callCountUsedRatio: number | null;
recordingStorageUsedRatio: number | null;
} | null;
}