feat(frontend): app shell + login + platform dashboard + billing tarifas

Primeiro commit do frontend Next.js (agente.md secao 161-176): login
split-brand, dashboard "Visão Geral da Plataforma" com instrumentos ao
vivo, e a tela Billing > Tarifas (price books + rate decks) completa —
listagem com busca/ordenacao, criacao com itens/entradas dinamicos via
Server Actions, detalhe — ponta a ponta contra a API real de billing
(fase 22).

Corrige de quebra 2 bugs reais achados construindo Tarifas: a topbar
tinha o titulo fixo "Visao Geral" em toda pagina, e a sidebar fixa de
256px nao tinha nenhuma versao mobile (conteudo espremido em ~130px) —
agora vira drawer via Radix Dialog abaixo de lg, com titulo/descricao da
topbar resolvidos dinamicamente por rota.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 00:54:29 -03:00
parent b27cfaab02
commit 673553975e
55 changed files with 2504 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { API_BASE_URL } from "@/lib/api";
import { sessionCookieName } from "@/lib/session";
export async function POST(request: Request) {
const { email, password } = await request.json();
const res = await fetch(`${API_BASE_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
cache: "no-store",
});
if (!res.ok) {
const body = await res.json().catch(() => ({ message: "Falha ao entrar" }));
return NextResponse.json({ message: body.message ?? "Falha ao entrar" }, { status: res.status });
}
const data = await res.json();
if (data.mustChangePassword) {
return NextResponse.json(
{ message: "Senha precisa ser trocada antes do primeiro acesso (use a API /auth/change-password por enquanto)." },
{ status: 403 },
);
}
const response = NextResponse.json({ ok: true });
response.cookies.set(sessionCookieName(), JSON.stringify({ accessToken: data.accessToken, refreshToken: data.refreshToken }), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 8,
});
return response;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { sessionCookieName } from "@/lib/session";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.delete(sessionCookieName());
return response;
}

View File

@@ -0,0 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Tokens (agente.md secao 165: colors/typography/spacing/radius/shadows/
* status colors). Paleta derivada do logo real (apps/frontend/public/
* branding/b2blogo.png): navy profundo + azul saturado + teal de apoio —
* nunca uma paleta nova escolhida livremente (agente.md secao "Brand
* Commitments" em PRODUCT.md).
*/
:root {
--background: 210 40% 98%;
--foreground: 224 45% 11%;
--surface: 0 0% 100%;
--surface-raised: 210 35% 99%;
--border: 220 20% 88%;
--input: 220 20% 86%;
--muted: 220 22% 95%;
--muted-foreground: 220 12% 42%;
--primary: 226 100% 59%;
--primary-foreground: 0 0% 100%;
--accent: 175 63% 40%;
--accent-foreground: 0 0% 100%;
--destructive: 358 75% 48%;
--destructive-foreground: 0 0% 100%;
--ring: 226 100% 59%;
--shadow-color: 224 40% 20%;
--radius: 10px;
--status-gray: 220 10% 52%;
--status-blue: 226 100% 59%;
--status-green: 152 58% 36%;
--status-yellow: 40 92% 46%;
--status-orange: 25 92% 50%;
--status-red: 358 75% 48%;
--status-green-dark: 152 62% 22%;
}
@media (prefers-color-scheme: dark) {
html:not(.light) {
--background: 224 38% 7%;
--foreground: 210 30% 93%;
--surface: 224 32% 10%;
--surface-raised: 224 28% 13%;
--border: 224 22% 20%;
--input: 224 22% 22%;
--muted: 224 22% 15%;
--muted-foreground: 220 14% 64%;
--primary: 226 100% 68%;
--primary-foreground: 224 45% 9%;
--accent: 175 58% 54%;
--accent-foreground: 224 45% 9%;
--destructive: 358 82% 64%;
--destructive-foreground: 224 45% 9%;
--ring: 226 100% 68%;
--shadow-color: 0 0% 0%;
--status-gray: 220 10% 66%;
--status-blue: 226 100% 68%;
--status-green: 152 55% 54%;
--status-yellow: 40 95% 62%;
--status-orange: 25 95% 62%;
--status-red: 358 85% 68%;
--status-green-dark: 152 45% 40%;
}
}
html.dark {
--background: 224 38% 7%;
--foreground: 210 30% 93%;
--surface: 224 32% 10%;
--surface-raised: 224 28% 13%;
--border: 224 22% 20%;
--input: 224 22% 22%;
--muted: 224 22% 15%;
--muted-foreground: 220 14% 64%;
--primary: 226 100% 68%;
--primary-foreground: 224 45% 9%;
--accent: 175 58% 54%;
--accent-foreground: 224 45% 9%;
--destructive: 358 82% 64%;
--destructive-foreground: 224 45% 9%;
--ring: 226 100% 68%;
--shadow-color: 0 0% 0%;
--status-gray: 220 10% 66%;
--status-blue: 226 100% 68%;
--status-green: 152 55% 54%;
--status-yellow: 40 95% 62%;
--status-orange: 25 95% 62%;
--status-red: 358 85% 68%;
--status-green-dark: 152 45% 40%;
}
* {
border-color: hsl(var(--border));
}
html {
color-scheme: light dark;
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings:
"cv02",
"cv03",
"cv04",
"cv11";
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";
const plexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-sans",
display: "swap",
});
const plexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "B2BCall",
description: "Plataforma SaaS de call center, discador preditivo e IA — B2BCall.",
};
// Aplica o tema salvo ANTES do primeiro paint (sem isso, uma preferencia
// dark salva pisca a versao light por um frame — FOUC classico de theming
// client-side). Roda sincrono no <head>, antes de qualquer CSS/JS de app.
const THEME_INIT_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem("b2bcall-theme");
if (stored === "light" || stored === "dark") {
document.documentElement.classList.add(stored);
}
} catch (e) {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" className={`${plexSans.variable} ${plexMono.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body className="font-sans antialiased">
{/*
THESIS: B2BCall's dashboards read as a live instrument panel, not a generic admin table — the platform's pulse before any list.
OWN-WORLD: navy #0B1B3A ground, brand blue #2554FF primary, teal #2EC9C0 accent (from b2blogo.png); IBM Plex Sans UI, IBM Plex Mono tabular readouts.
STORY: a platform admin opens the dashboard and reads the whole platform's live state — tenants, calls in flight, capacity headroom — in one glance.
FIRST VIEWPORT: sidebar+topbar shell, content opens on a grid of large mono instrument tiles for live counters, live ones pulse; a quieter row below for not-yet-available billing figures, explicitly marked pending.
FORM: concept-seed surface/operate key ae95b901, candidate #3 (seven-segment/instrument panel) chosen over dealt lead #4 (HyperCard) — reasoned override, no interactive decision round available in this environment, disclosed to the user.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance.
*/}
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Button } from "@/components/ui/button";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ message: "Não foi possível entrar." }));
setError(body.message ?? "Não foi possível entrar.");
return;
}
router.push("/platform");
router.refresh();
} catch {
setError("Não foi possível entrar. Verifique sua conexão.");
} finally {
setLoading(false);
}
}
return (
<div className="grid min-h-dvh grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)]">
{/* Brand — section 167: "Brand B2BCall | Login Form" split. Grade
fantasma de fundo ecoa a leitura de instrumento/painel do resto
do produto, sem competir com o formulário. */}
<div className="relative hidden overflow-hidden bg-[hsl(224_45%_11%)] lg:flex lg:flex-col lg:justify-between lg:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"linear-gradient(hsl(210 100% 90%) 1px, transparent 1px), linear-gradient(90deg, hsl(210 100% 90%) 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
<Image src="/branding/b2blogo.png" alt="B2BCall" width={180} height={44} priority className="relative brightness-0 invert" />
<div className="relative max-w-md">
<p className="text-2xl font-semibold leading-snug text-white">
Discador preditivo, call center e análise de IA sob um único painel de controle.
</p>
<p className="mt-4 text-sm text-white/60">
Isolamento completo por tenant, tarifação em tempo real e visibilidade de ponta a ponta sobre cada
chamada.
</p>
</div>
</div>
{/* Form */}
<div className="flex flex-1 flex-col justify-center px-6 py-12 sm:px-12 lg:px-16">
<div className="mx-auto w-full max-w-sm">
<Image src="/branding/b2blogo.png" alt="B2BCall" width={140} height={34} priority className="mb-10 lg:hidden" />
<h1 className="text-xl font-semibold text-foreground">Entrar no B2BCall</h1>
<p className="mt-1 text-sm text-muted-foreground">Acesse com as credenciais da sua conta.</p>
<form onSubmit={onSubmit} className="mt-8 space-y-4" noValidate>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
E-mail
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-input bg-surface px-3 py-2 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
placeholder="voce@empresa.com"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-foreground">
Senha
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-input bg-surface px-3 py-2 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
placeholder="••••••••"
/>
</div>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Entrando..." : "Entrar"}
</Button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
export default async function RootPage() {
const session = await getSession();
redirect(session ? "/platform" : "/login");
}

View File

@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { PriceBook, RateDeck } 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 — usa a mensagem crua
}
return err.message || "Falha inesperada na API.";
}
return "Falha inesperada. Tente novamente.";
}
export interface CreatePriceBookItemInput {
type: string;
unitPrice: number;
effectiveFrom: string;
}
export interface CreatePriceBookInput {
name: string;
currency: string;
isDefault: boolean;
items: CreatePriceBookItemInput[];
}
export async function createPriceBook(input: CreatePriceBookInput): Promise<{ error: string } | { error: null }> {
const session = await requireSession();
try {
const priceBook = await apiFetch<PriceBook>("/billing/price-books", session.accessToken, {
method: "POST",
body: JSON.stringify(input),
});
revalidatePath("/platform/billing/tarifas");
redirect(`/platform/billing/tarifas/price-books/${priceBook.id}`);
} catch (err) {
if (isNextRedirect(err)) throw err;
return { error: extractErrorMessage(err) };
}
}
export interface CreateRateDeckEntryInput {
prefix: string;
destinationName: string;
destinationType: string;
pricePerMinute: number;
billingIncrementSeconds: number;
minimumSeconds: number;
connectionFee: number;
validFrom: string;
}
export interface CreateRateDeckInput {
name: string;
isDefault: boolean;
entries: CreateRateDeckEntryInput[];
}
export async function createRateDeck(input: CreateRateDeckInput): Promise<{ error: string } | { error: null }> {
const session = await requireSession();
try {
const rateDeck = await apiFetch<RateDeck>("/billing/rate-decks", session.accessToken, {
method: "POST",
body: JSON.stringify(input),
});
revalidatePath("/platform/billing/tarifas");
redirect(`/platform/billing/tarifas/rate-decks/${rateDeck.id}`);
} catch (err) {
if (isNextRedirect(err)) throw err;
return { error: extractErrorMessage(err) };
}
}
/** `redirect()` do Next lança uma exceção especial que precisa propagar
* intacta — se ela cair no catch acima e virar `{ error }`, o redirect
* nunca acontece e o usuário fica preso na tela de criação. */
function isNextRedirect(err: unknown): boolean {
return typeof err === "object" && err !== null && "digest" in err && String((err as { digest: unknown }).digest).startsWith("NEXT_REDIRECT");
}

View File

@@ -0,0 +1,14 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { PriceBook, RateDeck } from "@/lib/billing-types";
import { TarifasView } from "./tarifas-view";
export default async function TarifasPage() {
const session = await requireSession();
const [priceBooks, rateDecks] = await Promise.all([
apiFetch<PriceBook[]>("/billing/price-books", session.accessToken),
apiFetch<RateDeck[]>("/billing/rate-decks", session.accessToken),
]);
return <TarifasView priceBooks={priceBooks} rateDecks={rateDecks} />;
}

View File

@@ -0,0 +1,73 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronLeft } from "lucide-react";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill";
import { Table, THead, TBody, TR, TH, TD } from "@/components/ui/table";
import { formatCurrency, formatDate, PRICE_ITEM_TYPE_LABELS } from "@/lib/format";
import type { PriceBook } from "@/lib/billing-types";
export default async function PriceBookDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await requireSession();
let priceBook: PriceBook;
try {
priceBook = await apiFetch<PriceBook>(`/billing/price-books/${id}`, session.accessToken);
} catch (err) {
if (err instanceof ApiError && err.status === 404) notFound();
throw err;
}
const sortedItems = [...priceBook.items].sort((a, b) => a.type.localeCompare(b.type));
return (
<div className="mx-auto max-w-3xl space-y-5">
<Link
href="/platform/billing/tarifas"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
Tarifas
</Link>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold text-foreground">{priceBook.name}</h1>
{priceBook.isDefault && <Pill tone="accent">Padrão da plataforma</Pill>}
</div>
<p className="-mt-3 text-sm text-muted-foreground">
Moeda <span className="font-mono">{priceBook.currency}</span> · atualizado em {formatDate(priceBook.updatedAt)}
</p>
<Panel>
<PanelHeader title="Itens de preço" description={`${sortedItems.length} tipo(s) de item cadastrado(s)`} />
{sortedItems.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-muted-foreground">Nenhum item cadastrado neste price book.</p>
) : (
<Table>
<THead>
<TR>
<TH>Item</TH>
<TH>Preço unitário</TH>
<TH>Vigente desde</TH>
<TH>Vigente até</TH>
</TR>
</THead>
<TBody>
{sortedItems.map((item) => (
<TR key={item.id}>
<TD className="font-medium">{PRICE_ITEM_TYPE_LABELS[item.type] ?? item.type}</TD>
<TD className="font-mono tabular-nums">{formatCurrency(item.unitPrice, priceBook.currency)}</TD>
<TD className="text-muted-foreground">{formatDate(item.effectiveFrom)}</TD>
<TD className="text-muted-foreground">{item.effectiveUntil ? formatDate(item.effectiveUntil) : "em aberto"}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Panel } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { PRICE_ITEM_TYPE_LABELS, PRICE_ITEM_TYPES } from "@/lib/format";
import { createPriceBook, type CreatePriceBookItemInput } from "../../actions";
const TODAY = new Date().toISOString().slice(0, 10);
function emptyItem(type: string): CreatePriceBookItemInput {
return { type, unitPrice: 0, effectiveFrom: TODAY };
}
export function NewPriceBookForm() {
const [name, setName] = useState("");
const [currency, setCurrency] = useState("BRL");
const [isDefault, setIsDefault] = useState(false);
const [items, setItems] = useState<CreatePriceBookItemInput[]>([emptyItem("CALL_MINUTE")]);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const usedTypes = new Set(items.map((i) => i.type));
const nextAvailableType = PRICE_ITEM_TYPES.find((t) => !usedTypes.has(t)) ?? PRICE_ITEM_TYPES[0];
function updateItem(index: number, patch: Partial<CreatePriceBookItemInput>) {
setItems((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
function removeItem(index: number) {
setItems((rows) => rows.filter((_, i) => i !== index));
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Dê um nome ao price book.");
return;
}
if (items.length === 0) {
setError("Adicione pelo menos um item de preço.");
return;
}
startTransition(async () => {
const result = await createPriceBook({ name: name.trim(), currency, isDefault, items });
if (result?.error) setError(result.error);
});
}
return (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<Panel className="p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_120px]">
<div>
<FieldLabel htmlFor="pb-name">Nome</FieldLabel>
<Input id="pb-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Padrão Brasil 2026" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="pb-currency">Moeda</FieldLabel>
<Input
id="pb-currency"
value={currency}
maxLength={3}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
className="uppercase"
disabled={pending}
/>
</div>
</div>
<label className="mt-4 flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
disabled={pending}
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
Usar como padrão da plataforma
</label>
</Panel>
<Panel>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">Itens de preço</h2>
<p className="mt-0.5 text-xs text-muted-foreground">Um preço unitário por tipo sem duplicar tipos nesta versão.</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setItems((rows) => [...rows, emptyItem(nextAvailableType)])}
disabled={pending || items.length >= PRICE_ITEM_TYPES.length}
>
<Plus className="h-3.5 w-3.5" aria-hidden /> Item
</Button>
</div>
<div className="divide-y divide-border">
{items.map((item, index) => (
<div key={index} className="grid grid-cols-1 gap-3 px-5 py-4 sm:grid-cols-[1.4fr_1fr_1fr_auto] sm:items-end">
<div>
<FieldLabel htmlFor={`pb-item-type-${index}`}>Tipo</FieldLabel>
<Select
id={`pb-item-type-${index}`}
value={item.type}
onChange={(e) => updateItem(index, { type: e.target.value })}
disabled={pending}
>
{PRICE_ITEM_TYPES.map((type) => (
<option key={type} value={type} disabled={usedTypes.has(type) && type !== item.type}>
{PRICE_ITEM_TYPE_LABELS[type]}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor={`pb-item-price-${index}`}>Preço unitário ({currency || "—"})</FieldLabel>
<Input
id={`pb-item-price-${index}`}
type="number"
step="any"
min={0}
value={item.unitPrice}
onChange={(e) => updateItem(index, { unitPrice: Number(e.target.value) })}
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor={`pb-item-from-${index}`}>Vigente desde</FieldLabel>
<Input
id={`pb-item-from-${index}`}
type="date"
value={item.effectiveFrom}
onChange={(e) => updateItem(index, { effectiveFrom: e.target.value })}
disabled={pending}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(index)}
disabled={pending}
aria-label={`Remover item ${PRICE_ITEM_TYPE_LABELS[item.type] ?? item.type}`}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
))}
{items.length === 0 && <p className="px-5 py-6 text-sm text-muted-foreground">Nenhum item ainda adicione ao menos um.</p>}
</div>
</Panel>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-3">
<Button type="submit" disabled={pending}>
{pending ? "Criando…" : "Criar price book"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { NewPriceBookForm } from "./form";
export default function NewPriceBookPage() {
return (
<div className="mx-auto max-w-2xl space-y-5">
<Link
href="/platform/billing/tarifas"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
Tarifas
</Link>
<div>
<h1 className="text-lg font-semibold text-foreground">Novo price book</h1>
<p className="mt-1 text-sm text-muted-foreground">
Um preço unitário por tipo de item de consumo. Marcar como padrão faz este ser o price book de qualquer
tenant sem um catálogo específico atribuído.
</p>
</div>
<NewPriceBookForm />
</div>
);
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronLeft } from "lucide-react";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill";
import { Table, THead, TBody, TR, TH, TD } from "@/components/ui/table";
import { formatDate, DESTINATION_TYPE_LABELS } from "@/lib/format";
import type { RateDeck } from "@/lib/billing-types";
export default async function RateDeckDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await requireSession();
let rateDeck: RateDeck;
try {
rateDeck = await apiFetch<RateDeck>(`/billing/rate-decks/${id}`, session.accessToken);
} catch (err) {
if (err instanceof ApiError && err.status === 404) notFound();
throw err;
}
const sortedEntries = [...rateDeck.entries].sort((a, b) => a.prefix.localeCompare(b.prefix));
return (
<div className="mx-auto max-w-4xl space-y-5">
<Link
href="/platform/billing/tarifas"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
Tarifas
</Link>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold text-foreground">{rateDeck.name}</h1>
{rateDeck.isDefault && <Pill tone="accent">Padrão da plataforma</Pill>}
</div>
<p className="-mt-3 text-sm text-muted-foreground">Atualizado em {formatDate(rateDeck.updatedAt)}</p>
<Panel>
<PanelHeader title="Entradas por destino" description={`${sortedEntries.length} prefixo(s) cadastrado(s), longest-prefix match`} />
{sortedEntries.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-muted-foreground">Nenhuma entrada cadastrada neste rate deck.</p>
) : (
<Table>
<THead>
<TR>
<TH>Prefixo</TH>
<TH>Destino</TH>
<TH>Tipo</TH>
<TH>R$/min</TH>
<TH>Incremento</TH>
<TH>Mínimo</TH>
<TH>Conexão</TH>
<TH>Vigente desde</TH>
</TR>
</THead>
<TBody>
{sortedEntries.map((entry) => (
<TR key={entry.id}>
<TD className="font-mono font-medium tabular-nums">{entry.prefix}</TD>
<TD>{entry.destinationName}</TD>
<TD>
<Pill>{DESTINATION_TYPE_LABELS[entry.destinationType] ?? entry.destinationType}</Pill>
</TD>
<TD className="font-mono tabular-nums">{entry.pricePerMinute.toFixed(4)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{entry.billingIncrementSeconds}s</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{entry.minimumSeconds}s</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{entry.connectionFee.toFixed(4)}</TD>
<TD className="text-muted-foreground">{formatDate(entry.validFrom)}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useState, useTransition } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Panel } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { DESTINATION_TYPE_LABELS, DESTINATION_TYPES } from "@/lib/format";
import { createRateDeck, type CreateRateDeckEntryInput } from "../../actions";
const TODAY = new Date().toISOString().slice(0, 10);
function emptyEntry(): CreateRateDeckEntryInput {
return {
prefix: "",
destinationName: "",
destinationType: "FIXED",
pricePerMinute: 0,
billingIncrementSeconds: 60,
minimumSeconds: 0,
connectionFee: 0,
validFrom: TODAY,
};
}
export function NewRateDeckForm() {
const [name, setName] = useState("");
const [isDefault, setIsDefault] = useState(false);
const [entries, setEntries] = useState<CreateRateDeckEntryInput[]>([emptyEntry()]);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function updateEntry(index: number, patch: Partial<CreateRateDeckEntryInput>) {
setEntries((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
function removeEntry(index: number) {
setEntries((rows) => rows.filter((_, i) => i !== index));
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Dê um nome ao rate deck.");
return;
}
if (entries.some((entry) => !entry.prefix.trim() || !entry.destinationName.trim())) {
setError("Toda entrada precisa de prefixo e nome de destino.");
return;
}
startTransition(async () => {
const result = await createRateDeck({ name: name.trim(), isDefault, entries });
if (result?.error) setError(result.error);
});
}
return (
<form onSubmit={onSubmit} noValidate className="space-y-5">
<Panel className="p-5">
<FieldLabel htmlFor="rd-name">Nome</FieldLabel>
<Input id="rd-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: BR — Fixo e Móvel" disabled={pending} />
<label className="mt-4 flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
disabled={pending}
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
Usar como padrão da plataforma
</label>
</Panel>
<Panel>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">Entradas por destino</h2>
<p className="mt-0.5 text-xs text-muted-foreground">O prefixo mais específico vence quando mais de um bate.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setEntries((rows) => [...rows, emptyEntry()])} disabled={pending}>
<Plus className="h-3.5 w-3.5" aria-hidden /> Entrada
</Button>
</div>
<div className="divide-y divide-border">
{entries.map((entry, index) => (
<div key={index} className="space-y-3 px-5 py-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[100px_1.4fr_1fr_auto]">
<div>
<FieldLabel htmlFor={`rd-prefix-${index}`}>Prefixo</FieldLabel>
<Input
id={`rd-prefix-${index}`}
value={entry.prefix}
onChange={(e) => updateEntry(index, { prefix: e.target.value.replace(/[^0-9]/g, "") })}
placeholder="5511"
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor={`rd-dest-${index}`}>Destino</FieldLabel>
<Input
id={`rd-dest-${index}`}
value={entry.destinationName}
onChange={(e) => updateEntry(index, { destinationName: e.target.value })}
placeholder="São Paulo — Capital"
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor={`rd-type-${index}`}>Tipo</FieldLabel>
<Select
id={`rd-type-${index}`}
value={entry.destinationType}
onChange={(e) => updateEntry(index, { destinationType: e.target.value })}
disabled={pending}
>
{DESTINATION_TYPES.map((t) => (
<option key={t} value={t}>
{DESTINATION_TYPE_LABELS[t]}
</option>
))}
</Select>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeEntry(index)}
disabled={pending}
aria-label={`Remover entrada ${entry.destinationName || index + 1}`}
className="mt-auto text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div>
<FieldLabel htmlFor={`rd-price-${index}`}>R$/min</FieldLabel>
<Input
id={`rd-price-${index}`}
type="number"
step="any"
min={0}
value={entry.pricePerMinute}
onChange={(e) => updateEntry(index, { pricePerMinute: Number(e.target.value) })}
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor={`rd-incr-${index}`}>Incremento (s)</FieldLabel>
<Input
id={`rd-incr-${index}`}
type="number"
min={1}
value={entry.billingIncrementSeconds}
onChange={(e) => updateEntry(index, { billingIncrementSeconds: Number(e.target.value) })}
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor={`rd-min-${index}`}>Mínimo (s)</FieldLabel>
<Input
id={`rd-min-${index}`}
type="number"
min={0}
value={entry.minimumSeconds}
onChange={(e) => updateEntry(index, { minimumSeconds: Number(e.target.value) })}
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor={`rd-fee-${index}`}>Conexão (R$)</FieldLabel>
<Input
id={`rd-fee-${index}`}
type="number"
step="any"
min={0}
value={entry.connectionFee}
onChange={(e) => updateEntry(index, { connectionFee: Number(e.target.value) })}
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor={`rd-from-${index}`}>Vigente desde</FieldLabel>
<Input
id={`rd-from-${index}`}
type="date"
value={entry.validFrom}
onChange={(e) => updateEntry(index, { validFrom: e.target.value })}
disabled={pending}
/>
</div>
</div>
</div>
))}
</div>
</Panel>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-3">
<Button type="submit" disabled={pending}>
{pending ? "Criando…" : "Criar rate deck"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { NewRateDeckForm } from "./form";
export default function NewRateDeckPage() {
return (
<div className="mx-auto max-w-3xl space-y-5">
<Link
href="/platform/billing/tarifas"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
Tarifas
</Link>
<div>
<h1 className="text-lg font-semibold text-foreground">Novo rate deck</h1>
<p className="mt-1 text-sm text-muted-foreground">
Tarifa por prefixo de destino (longest-prefix match). Marcar como padrão faz este ser o rate deck de
qualquer tenant sem um catálogo específico atribuído.
</p>
</div>
<NewRateDeckForm />
</div>
);
}

View File

@@ -0,0 +1,263 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { ArrowUpDown, ListTree, Percent, Plus, Search, SlidersHorizontal } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/format";
import type { PriceBook, RateDeck } from "@/lib/billing-types";
type Tab = "price-books" | "rate-decks";
export function TarifasView({ priceBooks, rateDecks }: { priceBooks: PriceBook[]; rateDecks: RateDeck[] }) {
const [tab, setTab] = useState<Tab>("price-books");
const [query, setQuery] = useState("");
const [sortDesc, setSortDesc] = useState(true);
const filteredPriceBooks = useMemo(() => {
const rows = priceBooks.filter((pb) => pb.name.toLowerCase().includes(query.toLowerCase()));
return rows.sort((a, b) => (sortDesc ? b.updatedAt.localeCompare(a.updatedAt) : a.updatedAt.localeCompare(b.updatedAt)));
}, [priceBooks, query, sortDesc]);
const filteredRateDecks = useMemo(() => {
const rows = rateDecks.filter((rd) => rd.name.toLowerCase().includes(query.toLowerCase()));
return rows.sort((a, b) => (sortDesc ? b.updatedAt.localeCompare(a.updatedAt) : a.updatedAt.localeCompare(b.updatedAt)));
}, [rateDecks, query, sortDesc]);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">Tarifas</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Catálogos globais de preço (agente.md secao 128-129). Cada tenant usa o que tiver <strong>Padrão</strong>{" "}
a menos que uma atribuição específica diga o contrário atribuir um catálogo a um tenant é feito em
Clientes.
</p>
</div>
<Button asChild>
<Link href={tab === "price-books" ? "/platform/billing/tarifas/price-books/new" : "/platform/billing/tarifas/rate-decks/new"}>
<Plus className="h-4 w-4" aria-hidden />
{tab === "price-books" ? "Novo price book" : "Novo rate deck"}
</Link>
</Button>
</div>
<div role="tablist" aria-label="Tipo de catálogo" className="flex gap-1 border-b border-border">
<TabButton active={tab === "price-books"} onClick={() => setTab("price-books")} icon={Percent} count={priceBooks.length}>
Price books
</TabButton>
<TabButton active={tab === "rate-decks"} onClick={() => setTab("rate-decks")} icon={ListTree} count={rateDecks.length}>
Rate decks
</TabButton>
</div>
<Panel>
<PanelHeader
title={tab === "price-books" ? "Preço por item de consumo" : "Tarifa por prefixo de destino"}
description={
tab === "price-books"
? "Preço unitário por tipo de item (ramal, agente, minuto, IA, armazenamento)."
: "Longest-prefix match — a entrada com o prefixo mais específico vence a tarifação."
}
/>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
<div className="relative w-full max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === "price-books" ? "Buscar price book…" : "Buscar rate deck…"}
className="pl-8"
aria-label="Buscar por nome"
/>
</div>
<button
type="button"
onClick={() => setSortDesc((d) => !d)}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
{sortDesc ? "Mais recentes primeiro" : "Mais antigos primeiro"}
</button>
</div>
{tab === "price-books" ? (
filteredPriceBooks.length === 0 ? (
<PriceBooksEmpty hasAny={priceBooks.length > 0} />
) : (
<PriceBooksTable rows={filteredPriceBooks} sortDesc={sortDesc} onToggleSort={() => setSortDesc((d) => !d)} />
)
) : filteredRateDecks.length === 0 ? (
<RateDecksEmpty hasAny={rateDecks.length > 0} />
) : (
<RateDecksTable rows={filteredRateDecks} sortDesc={sortDesc} onToggleSort={() => setSortDesc((d) => !d)} />
)}
<div className="flex items-center gap-2 border-t border-border px-5 py-2.5 text-[11px] text-muted-foreground">
<SlidersHorizontal className="h-3 w-3 shrink-0" aria-hidden />
Catálogo global sem paginação de servidor por enquanto (poucas dezenas de linhas esperadas; ver
docs/BILLING.md).
</div>
</Panel>
</div>
);
}
function TabButton({
active,
onClick,
icon: Icon,
count,
children,
}: {
active: boolean;
onClick: () => void;
icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
count: number;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={cn(
"flex items-center gap-2 border-b-2 px-3 py-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
active ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<Icon className="h-4 w-4" aria-hidden />
{children}
<span
className={cn(
"rounded-full px-1.5 py-0.5 font-mono text-[11px] tabular-nums",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{count}
</span>
</button>
);
}
function PriceBooksTable({ rows, sortDesc, onToggleSort }: { rows: PriceBook[]; sortDesc: boolean; onToggleSort: () => void }) {
return (
<Table>
<THead>
<TR>
<TH>Nome</TH>
<TH>Moeda</TH>
<TH>Itens</TH>
<TH>Padrão</TH>
<TH onClick={onToggleSort} aria-sort={sortDesc ? "descending" : "ascending"}>
Atualizado
</TH>
</TR>
</THead>
<TBody>
{rows.map((pb) => (
<TR key={pb.id}>
<TD>
<Link
href={`/platform/billing/tarifas/price-books/${pb.id}`}
className="font-medium text-foreground underline-offset-4 hover:text-primary hover:underline focus-visible:underline"
>
{pb.name}
</Link>
</TD>
<TD className="font-mono text-xs text-muted-foreground">{pb.currency}</TD>
<TD className="font-mono tabular-nums">{pb.items.length}</TD>
<TD>{pb.isDefault ? <Pill tone="accent">Padrão</Pill> : <span className="text-muted-foreground"></span>}</TD>
<TD className="text-muted-foreground">{formatDate(pb.updatedAt)}</TD>
</TR>
))}
</TBody>
</Table>
);
}
function RateDecksTable({ rows, sortDesc, onToggleSort }: { rows: RateDeck[]; sortDesc: boolean; onToggleSort: () => void }) {
return (
<Table>
<THead>
<TR>
<TH>Nome</TH>
<TH>Prefixos</TH>
<TH>Padrão</TH>
<TH onClick={onToggleSort} aria-sort={sortDesc ? "descending" : "ascending"}>
Atualizado
</TH>
</TR>
</THead>
<TBody>
{rows.map((rd) => (
<TR key={rd.id}>
<TD>
<Link
href={`/platform/billing/tarifas/rate-decks/${rd.id}`}
className="font-medium text-foreground underline-offset-4 hover:text-primary hover:underline focus-visible:underline"
>
{rd.name}
</Link>
</TD>
<TD className="font-mono tabular-nums">{rd.entries.length}</TD>
<TD>{rd.isDefault ? <Pill tone="accent">Padrão</Pill> : <span className="text-muted-foreground"></span>}</TD>
<TD className="text-muted-foreground">{formatDate(rd.updatedAt)}</TD>
</TR>
))}
</TBody>
</Table>
);
}
function PriceBooksEmpty({ hasAny }: { hasAny: boolean }) {
return (
<EmptyState
title={hasAny ? "Nenhum price book bate com essa busca" : "Nenhum price book cadastrado ainda"}
description={
hasAny
? "Tente outro termo de busca."
: "Sem um price book padrão, o fechamento de billing não consegue tarifar EXTENSION_MONTH, AI, armazenamento e o fallback de minutos de nenhum tenant (docs/BILLING.md)."
}
action={
!hasAny && (
<Button asChild>
<Link href="/platform/billing/tarifas/price-books/new">
<Plus className="h-4 w-4" aria-hidden /> Criar o primeiro price book
</Link>
</Button>
)
}
/>
);
}
function RateDecksEmpty({ hasAny }: { hasAny: boolean }) {
return (
<EmptyState
title={hasAny ? "Nenhum rate deck bate com essa busca" : "Nenhum rate deck cadastrado ainda"}
description={
hasAny
? "Tente outro termo de busca."
: "Sem um rate deck, chamadas sempre usam o preço genérico de CALL_MINUTE do price book — o rate deck só entra em jogo quando o CDR passar a registrar o número discado (lacuna aberta em docs/BILLING.md)."
}
action={
!hasAny && (
<Button asChild>
<Link href="/platform/billing/tarifas/rate-decks/new">
<Plus className="h-4 w-4" aria-hidden /> Criar o primeiro rate deck
</Link>
</Button>
)
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { PlatformSidebar } from "@/components/platform-shell/sidebar";
import { PlatformTopbar } from "@/components/platform-shell/topbar";
interface Me {
id: string;
email: string;
name: string;
isPlatformUser: boolean;
}
export default async function PlatformLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession();
const me = await apiFetch<Me>("/auth/me", session.accessToken);
return (
<div className="flex h-dvh overflow-hidden bg-background">
<PlatformSidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<PlatformTopbar userEmail={me.email} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { InstrumentTile } from "@/components/ui/instrument-tile";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { formatBytes, formatInt, AI_USAGE_LABELS } from "@/lib/format";
interface PlatformOverview {
tenantsActive: number;
tenantsTotal: number;
extensionsTotal: number;
agentsTotal: number;
callsCurrent: number;
callsToday: number;
freeswitchNodes: number;
cpsCapacityConfigured: number | null;
aiUsageThisMonth: Record<string, number>;
recordingStorageBytes: number;
monthlyConsumption: number | null;
estimatedRevenue: number | null;
}
export default async function PlatformDashboardPage() {
const session = await requireSession();
const overview = await apiFetch<PlatformOverview>("/platform/overview", session.accessToken);
const storage = formatBytes(overview.recordingStorageBytes);
const aiUsageEntries = Object.entries(overview.aiUsageThisMonth);
return (
<div className="space-y-6">
{/* Leitura instantânea — instrumentos ao vivo primeiro, section 163 */}
<section aria-label="Estado ao vivo da plataforma" className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<InstrumentTile label="Tenants ativos" value={formatInt(overview.tenantsActive)} suffix={`de ${formatInt(overview.tenantsTotal)}`} />
<InstrumentTile label="Chamadas atuais" value={formatInt(overview.callsCurrent)} live />
<InstrumentTile label="Chamadas hoje" value={formatInt(overview.callsToday)} />
<InstrumentTile label="Ramais totais" value={formatInt(overview.extensionsTotal)} />
<InstrumentTile label="Agentes totais" value={formatInt(overview.agentsTotal)} />
<InstrumentTile label="FreeSWITCH nodes" value={formatInt(overview.freeswitchNodes)} />
<InstrumentTile
label="CPS global"
value={overview.cpsCapacityConfigured !== null ? formatInt(overview.cpsCapacityConfigured) : null}
unit="cps"
pending="Capacidade outorgada pelos planos — consumo em tempo real ainda não é lido do limitador."
/>
<InstrumentTile label="Storage de gravações" value={storage.value} unit={storage.unit} />
</section>
{/* Financeiro — ainda sem periodo de billing fechado nesta instalação */}
<section aria-label="Consumo e receita" className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InstrumentTile
label="Consumo mensal"
value={overview.monthlyConsumption}
pending="Nenhum período de billing foi fechado ainda — sem número real pra mostrar (nunca um valor inventado)."
/>
<InstrumentTile
label="Receita estimada"
value={overview.estimatedRevenue}
pending="Depende do fechamento de billing acima — mesma razão."
/>
</section>
<Panel>
<PanelHeader title="Uso de IA este mês" description="Consumo agregado de todos os tenants, ledger imutável" />
<div className="p-5">
{aiUsageEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum uso de IA registrado neste mês ainda.</p>
) : (
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{aiUsageEntries.map(([type, quantity]) => (
<div key={type}>
<dt className="text-xs text-muted-foreground">{AI_USAGE_LABELS[type] ?? type}</dt>
<dd className="mt-1 font-mono text-lg font-semibold tabular-nums text-foreground">
{formatInt(quantity)}
</dd>
</div>
))}
</dl>
)}
</div>
</Panel>
</div>
);
}