diff --git a/.gitignore b/.gitignore
index 5cfcb83..7ee9fa0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,10 @@ dist/
build/
.next/
.turbo/
+*.tsbuildinfo
+
+# Impeccable per-developer overrides (see .claude/skills/impeccable)
+.impeccable/config.local.json
# Logs
*.log
diff --git a/apps/frontend/.impeccable/review/dashboard-desktop.png b/apps/frontend/.impeccable/review/dashboard-desktop.png
new file mode 100644
index 0000000..de11c47
Binary files /dev/null and b/apps/frontend/.impeccable/review/dashboard-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/dashboard-mobile.png b/apps/frontend/.impeccable/review/dashboard-mobile.png
new file mode 100644
index 0000000..ccfbfc0
Binary files /dev/null and b/apps/frontend/.impeccable/review/dashboard-mobile.png differ
diff --git a/apps/frontend/.impeccable/review/price-book-detail-desktop.png b/apps/frontend/.impeccable/review/price-book-detail-desktop.png
new file mode 100644
index 0000000..7ea9a22
Binary files /dev/null and b/apps/frontend/.impeccable/review/price-book-detail-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/price-book-new-dark-desktop.png b/apps/frontend/.impeccable/review/price-book-new-dark-desktop.png
new file mode 100644
index 0000000..d9f898d
Binary files /dev/null and b/apps/frontend/.impeccable/review/price-book-new-dark-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/price-book-new-desktop.png b/apps/frontend/.impeccable/review/price-book-new-desktop.png
new file mode 100644
index 0000000..4e8822f
Binary files /dev/null and b/apps/frontend/.impeccable/review/price-book-new-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/price-book-new-mobile.png b/apps/frontend/.impeccable/review/price-book-new-mobile.png
new file mode 100644
index 0000000..91e59a6
Binary files /dev/null and b/apps/frontend/.impeccable/review/price-book-new-mobile.png differ
diff --git a/apps/frontend/.impeccable/review/rate-deck-new-desktop.png b/apps/frontend/.impeccable/review/rate-deck-new-desktop.png
new file mode 100644
index 0000000..a13f746
Binary files /dev/null and b/apps/frontend/.impeccable/review/rate-deck-new-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/rate-deck-new-mobile.png b/apps/frontend/.impeccable/review/rate-deck-new-mobile.png
new file mode 100644
index 0000000..405c74f
Binary files /dev/null and b/apps/frontend/.impeccable/review/rate-deck-new-mobile.png differ
diff --git a/apps/frontend/.impeccable/review/tarifas-dark-desktop.png b/apps/frontend/.impeccable/review/tarifas-dark-desktop.png
new file mode 100644
index 0000000..cc3e92f
Binary files /dev/null and b/apps/frontend/.impeccable/review/tarifas-dark-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/tarifas-empty-desktop.png b/apps/frontend/.impeccable/review/tarifas-empty-desktop.png
new file mode 100644
index 0000000..ef63184
Binary files /dev/null and b/apps/frontend/.impeccable/review/tarifas-empty-desktop.png differ
diff --git a/apps/frontend/.impeccable/review/tarifas-empty-mobile.png b/apps/frontend/.impeccable/review/tarifas-empty-mobile.png
new file mode 100644
index 0000000..6930e06
Binary files /dev/null and b/apps/frontend/.impeccable/review/tarifas-empty-mobile.png differ
diff --git a/apps/frontend/.impeccable/review/tarifas-with-data-desktop.png b/apps/frontend/.impeccable/review/tarifas-with-data-desktop.png
new file mode 100644
index 0000000..c6a8d34
Binary files /dev/null and b/apps/frontend/.impeccable/review/tarifas-with-data-desktop.png differ
diff --git a/apps/frontend/PRODUCT.md b/apps/frontend/PRODUCT.md
new file mode 100644
index 0000000..a2e2404
--- /dev/null
+++ b/apps/frontend/PRODUCT.md
@@ -0,0 +1,66 @@
+# Product
+
+
+
+## Platform
+
+web
+
+## Stack
+
+Next.js, React, TypeScript, Tailwind CSS, shadcn/ui, TanStack Query, TanStack Table, Recharts, Lucide Icons (specified explicitly in the project's own build spec, `agente.md`, "Frontend" section — not delegated).
+
+## Users
+
+Two audiences, both authenticated staff (never a public visitor):
+
+- **Platform admins** (B2BCall's own operators): manage tenants, plans/subscriptions, quotas, global infrastructure (FreeSWITCH nodes, SIP profiles, health), AI provider catalog, billing rate decks/price books, system users/permissions/audit.
+- **Tenant staff** (a client company's own team): tenant admins configure trunks/extensions/queues/campaigns/agents/AI settings/billing for their own account; agents work the live call queue; supervisors monitor campaigns/queues/agents in real time and review AI call analysis, quality scorecards, and reports.
+
+## Product Purpose
+
+B2BCall is a multi-tenant SaaS call-center platform: predictive dialer, inbound/outbound call center (FreeSWITCH-backed), AI-driven call transcription/analysis/QA scorecards, and usage-based billing, all under strict per-tenant data isolation. Success is a tenant running real campaigns and inbound queues, with agents connected and productive, in an interface that looks and feels like a modern SaaS product a client would trust with production traffic.
+
+## Positioning
+
+External, multi-tenant SaaS product sold to other companies running their own call centers — not an internal single-operator tool. The interface must carry plan/quota framing (a tenant sees its own consumption and limits) and customer-facing polish throughout, per the build spec's explicit instruction (section 164) that it must read as a modern professional SaaS product, never as an internal admin panel.
+
+## Operating Context
+
+Real-time operations are central: live call/agent/queue counters that move continuously, predictive-dialer campaigns paced against CPS/concurrency limits, agents moving through pause/wrap-up/available states, calls flowing through queues to agents. Supporting workflows: campaign creation (a defined 7-step wizard: General → Telephony → Dialing → Schedule → Recording & AI → Leads → Review), lead import/blocklist management, call recording playback with synced transcript, AI analysis review, quality scorecard evaluation, and monthly billing statement review (explicitly never called an invoice/nota fiscal — "Usage Statement" / "Billing Statement" / "Relatório de Consumo" only).
+
+## Capabilities and Constraints
+
+- Full navigation IA already specified for both audiences (`agente.md` sections 168-169): Platform menu = Visão Geral / Clientes (Tenants, Planos, Assinaturas, Quotas) / Billing (Consumo, Tarifas, Fechamentos, Relatórios) / Infraestrutura (FreeSWITCH, SIP Profiles, Nodes, Saúde) / IA (Providers, Modelos, Uso, Custos) / Sistema (Usuários, Permissões, Auditoria, Configurações). Tenant menu = Dashboard / Discador / Call Center / Telefonia / Monitoramento / Gravações / IA / Relatórios / Administração. Menu items the current user lacks permission for do not render.
+- Layout is fixed by spec: collapsible sidebar + topbar + content (section 166).
+- Server-side pagination, search, filters, sort, column visibility, row actions, bulk actions, and export are required on every data table (section 173).
+- Status must never be color-only (accessibility, section 176); consistent badge colors are specified per campaign status (DRAFT gray, READY blue, RUNNING green, PAUSED yellow, DRAINING orange, STOPPED red, COMPLETED dark green, ERROR red).
+- Light/Dark/System theming is required unless it compromises higher priorities (section 174).
+- Desktop/notebook/tablet are the priority breakpoints; the agent's own screen must work well at smaller resolutions too (section 175).
+- A real backend is live today (NestJS/Fastify REST API on `localhost:3000`, 20+ endpoints across auth, extensions, trunks, queues, agents, campaigns, leads, calls, recordings, AI providers/pipeline, quality scorecards) — this is not a mockup exercise; real data is available to wire against once auth is in place. No frontend code exists yet beyond this branding asset.
+- Backend has no billing UI-facing endpoints yet for the newest schema (rate decks, price books, billing periods/statements) — those are mid-build; the Platform Dashboard itself (tenants, quotas, infra health, AI usage) is fully backed today.
+
+## Brand Commitments
+
+- Name: **B2BCall**. Wordmark and mark exist at `apps/frontend/public/branding/b2blogo.png` — a rounded-square chat/analytics icon (bar-chart bars + phone handset + chat-bubble tail) paired with a two-tone "B2B" (dark navy) + "Call" (bright blue) wordmark. Palette read directly off the mark: deep navy (~#0B1B3A), a saturated blue (~#2554FF), and a teal accent (~#2EC9C0) used sparingly for one bar/dot. This is binding — build from these colors and this mark, not a freely chosen new palette.
+- Login screen must use this logo (section 167): "Brand B2BCall | Login Form" split layout on desktop, responsive down.
+- Explicit anti-brief (section 164): must **not** read as FreePBX, FusionPBX, a generic Linux admin panel, dated Bootstrap, or any legacy on-prem PBX tool.
+
+## Evidence on Hand
+
+- Real logo/mark: `apps/frontend/public/branding/b2blogo.png`.
+- Full build spec with exact page-level requirements for dashboards, menus, wizards, tables, and status semantics: `agente.md` sections 161-176 (and the whole document for domain vocabulary).
+- Live backend and database schema this session already built and tested end-to-end (see `docs/*.md` at the repo root — `ARCHITECTURE.md`, `PREDICTIVE_DIALER.md`, `AI_PIPELINE.md`, `QUALITY_SCORECARDS.md`, etc.).
+- No testimonials, customer logos, pricing page copy, or marketing claims exist — none should be fabricated; this build is internal admin/ops surfaces, not a marketing site.
+
+## Product Principles
+
+1. Real-time state is the product — counters, badges, and queues must read as genuinely live, not as a static report someone might refresh.
+2. Never let this look like the telecom/PBX tooling it's replacing (section 164) — every screen should read as a modern, venture-funded SaaS dashboard first, telecom tool second.
+3. Multi-tenant discipline shows up in the UI too — a tenant's plan/quota context (consumption vs. limit) is always nearby, never buried.
+4. Status is never color-only; every state has a label, not just a hue (section 176).
+5. Build the platform-admin surface first as the reference implementation of the design system (tokens, layout shell, table pattern, badge system) that every later tenant-facing screen extends, rather than a one-off.
+
+## Accessibility & Inclusion
+
+Explicit requirement (section 176): labels, full keyboard navigation, visible focus states, ARIA, sufficient contrast, and status conveyed by more than color alone.
diff --git a/apps/frontend/next-env.d.ts b/apps/frontend/next-env.d.ts
new file mode 100644
index 0000000..1b3be08
--- /dev/null
+++ b/apps/frontend/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts
new file mode 100644
index 0000000..9952bf9
--- /dev/null
+++ b/apps/frontend/next.config.ts
@@ -0,0 +1,15 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ // sharp (compilacao nativa) fica desligado de proposito em
+ // pnpm-workspace.yaml (allowBuilds.sharp: false — nao roda script de
+ // instalacao nao revisado so' pra otimizacao de imagem). unoptimized=true
+ // evita o pipeline de otimizacao inteiro em vez de depender de um
+ // fallback silencioso; os unicos assets sao a logo estatica, sem ganho
+ // real em comprimir de novo em runtime.
+ images: {
+ unoptimized: true,
+ },
+};
+
+export default nextConfig;
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
new file mode 100644
index 0000000..bb5f0a2
--- /dev/null
+++ b/apps/frontend/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@b2bcall/frontend",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "next": "15.3.9",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "@tanstack/react-query": "5.62.7",
+ "@tanstack/react-table": "8.20.5",
+ "recharts": "2.15.0",
+ "lucide-react": "0.469.0",
+ "clsx": "2.1.1",
+ "tailwind-merge": "2.5.5",
+ "class-variance-authority": "0.7.1",
+ "@radix-ui/react-slot": "1.1.1",
+ "@radix-ui/react-dialog": "1.1.4",
+ "@radix-ui/react-dropdown-menu": "2.1.4",
+ "@radix-ui/react-avatar": "1.1.2",
+ "@radix-ui/react-tooltip": "1.1.6",
+ "server-only": "0.0.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.1",
+ "@types/react": "19.1.0",
+ "@types/react-dom": "19.1.0",
+ "typescript": "^5.7.0",
+ "tailwindcss": "3.4.17",
+ "postcss": "8.4.49",
+ "autoprefixer": "10.4.20"
+ }
+}
diff --git a/apps/frontend/postcss.config.mjs b/apps/frontend/postcss.config.mjs
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/apps/frontend/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/frontend/src/app/api/login/route.ts b/apps/frontend/src/app/api/login/route.ts
new file mode 100644
index 0000000..c751fe8
--- /dev/null
+++ b/apps/frontend/src/app/api/login/route.ts
@@ -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;
+}
diff --git a/apps/frontend/src/app/api/logout/route.ts b/apps/frontend/src/app/api/logout/route.ts
new file mode 100644
index 0000000..a2ad0e6
--- /dev/null
+++ b/apps/frontend/src/app/api/logout/route.ts
@@ -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;
+}
diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css
new file mode 100644
index 0000000..444a7cf
--- /dev/null
+++ b/apps/frontend/src/app/globals.css
@@ -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;
+ }
+}
diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx
new file mode 100644
index 0000000..c94cedb
--- /dev/null
+++ b/apps/frontend/src/app/layout.tsx
@@ -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
, 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 (
+
+
+
+
+
+ {/*
+ 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}
+
+
+ );
+}
diff --git a/apps/frontend/src/app/login/page.tsx b/apps/frontend/src/app/login/page.tsx
new file mode 100644
index 0000000..a41c6f4
--- /dev/null
+++ b/apps/frontend/src/app/login/page.tsx
@@ -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(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 (
+
+ {/* 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. */}
+
+
+
+
+
+ Discador preditivo, call center e análise de IA sob um único painel de controle.
+
+
+ Isolamento completo por tenant, tarifação em tempo real e visibilidade de ponta a ponta sobre cada
+ chamada.
+
+
+
+
+ {/* Form */}
+
+
+
+
Entrar no B2BCall
+
Acesse com as credenciais da sua conta.
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx
new file mode 100644
index 0000000..9779145
--- /dev/null
+++ b/apps/frontend/src/app/page.tsx
@@ -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");
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/actions.ts b/apps/frontend/src/app/platform/billing/tarifas/actions.ts
new file mode 100644
index 0000000..ca55d86
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/actions.ts
@@ -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("/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("/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");
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/page.tsx b/apps/frontend/src/app/platform/billing/tarifas/page.tsx
new file mode 100644
index 0000000..f1b0ee7
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/page.tsx
@@ -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("/billing/price-books", session.accessToken),
+ apiFetch("/billing/rate-decks", session.accessToken),
+ ]);
+
+ return ;
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/price-books/[id]/page.tsx b/apps/frontend/src/app/platform/billing/tarifas/price-books/[id]/page.tsx
new file mode 100644
index 0000000..f7125d2
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/price-books/[id]/page.tsx
@@ -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(`/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 (
+
+
+
+ Tarifas
+
+
+
+
{priceBook.name}
+ {priceBook.isDefault && Padrão da plataforma}
+
+
+ Moeda {priceBook.currency} · atualizado em {formatDate(priceBook.updatedAt)}
+
+ );
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/price-books/new/form.tsx b/apps/frontend/src/app/platform/billing/tarifas/price-books/new/form.tsx
new file mode 100644
index 0000000..35b8d61
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/price-books/new/form.tsx
@@ -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([emptyItem("CALL_MINUTE")]);
+ const [error, setError] = useState(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) {
+ 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 (
+
+ );
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/price-books/new/page.tsx b/apps/frontend/src/app/platform/billing/tarifas/price-books/new/page.tsx
new file mode 100644
index 0000000..760ce01
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/price-books/new/page.tsx
@@ -0,0 +1,25 @@
+import Link from "next/link";
+import { ChevronLeft } from "lucide-react";
+import { NewPriceBookForm } from "./form";
+
+export default function NewPriceBookPage() {
+ return (
+
+
+
+ Tarifas
+
+
+
Novo price book
+
+ 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.
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/rate-decks/[id]/page.tsx b/apps/frontend/src/app/platform/billing/tarifas/rate-decks/[id]/page.tsx
new file mode 100644
index 0000000..f399453
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/rate-decks/[id]/page.tsx
@@ -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(`/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 (
+
+ );
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/rate-decks/new/form.tsx b/apps/frontend/src/app/platform/billing/tarifas/rate-decks/new/form.tsx
new file mode 100644
index 0000000..82ec107
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/rate-decks/new/form.tsx
@@ -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([emptyEntry()]);
+ const [error, setError] = useState(null);
+ const [pending, startTransition] = useTransition();
+
+ function updateEntry(index: number, patch: Partial) {
+ 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 (
+
+ );
+}
diff --git a/apps/frontend/src/app/platform/billing/tarifas/rate-decks/new/page.tsx b/apps/frontend/src/app/platform/billing/tarifas/rate-decks/new/page.tsx
new file mode 100644
index 0000000..6a9a5a4
--- /dev/null
+++ b/apps/frontend/src/app/platform/billing/tarifas/rate-decks/new/page.tsx
@@ -0,0 +1,25 @@
+import Link from "next/link";
+import { ChevronLeft } from "lucide-react";
+import { NewRateDeckForm } from "./form";
+
+export default function NewRateDeckPage() {
+ return (
+
+
+
+ Tarifas
+
+
+
Novo rate deck
+
+ 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.
+
+ Catálogos globais de preço (agente.md secao 128-129). Cada tenant usa o que tiver Padrão{" "}
+ a menos que uma atribuição específica diga o contrário — atribuir um catálogo a um tenant é feito em
+ Clientes.
+
+ {/* Leitura instantânea — instrumentos ao vivo primeiro, section 163 */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Financeiro — ainda sem periodo de billing fechado nesta instalação */}
+
+
+
+
+
+
+
+
+ {aiUsageEntries.length === 0 ? (
+
Nenhum uso de IA registrado neste mês ainda.
+ ) : (
+
+ {aiUsageEntries.map(([type, quantity]) => (
+
+
{AI_USAGE_LABELS[type] ?? type}
+
+ {formatInt(quantity)}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/frontend/src/components/platform-shell/mobile-nav-drawer.tsx b/apps/frontend/src/components/platform-shell/mobile-nav-drawer.tsx
new file mode 100644
index 0000000..a17a406
--- /dev/null
+++ b/apps/frontend/src/components/platform-shell/mobile-nav-drawer.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import * as Dialog from "@radix-ui/react-dialog";
+import { Menu, X } from "lucide-react";
+import { NavList } from "./nav-list";
+
+/** Drawer de navegação pra telas abaixo de `lg` (secao 175: notebook/
+ * tablet são prioridade, mas o próprio celular do agente precisa
+ * funcionar) — a sidebar fixa de 256px não cabe num viewport de celular.
+ * Radix Dialog dá foco preso + Escape + clique fora de graça (secao 176:
+ * navegação completa por teclado). */
+export function MobileNavDrawer() {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+ Navegação da plataforma
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts
new file mode 100644
index 0000000..e08a4bc
--- /dev/null
+++ b/apps/frontend/src/components/platform-shell/nav-data.ts
@@ -0,0 +1,95 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ LayoutDashboard,
+ Building2,
+ CreditCard,
+ ServerCog,
+ Sparkles,
+ Settings2,
+} from "lucide-react";
+
+export interface NavLeaf {
+ label: string;
+ href?: string;
+ /** Subtítulo mostrado na topbar quando esta rota está ativa. */
+ description?: string;
+}
+
+export interface NavSection {
+ label: string;
+ icon: LucideIcon;
+ href?: string;
+ description?: string;
+ children?: NavLeaf[];
+}
+
+/** IA fixa do menu Platform (agente.md secao 168) — "Visão Geral" e
+ * "Billing > Tarifas" têm página construída até agora, o resto existe pra
+ * provar a arquitetura completa (Product Principle #5 em PRODUCT.md: esta
+ * é a implementação de referência do design system pras próximas telas),
+ * renderizado como indisponível em vez de virar link morto. Os outros 3
+ * itens de Billing (Consumo/Fechamentos/Relatórios) esperam um seletor de
+ * tenant real — não existe endpoint de listagem de tenants ainda (ver
+ * docs/BILLING.md), então ficam "em breve" até essa peça existir. */
+export const PLATFORM_NAV: NavSection[] = [
+ {
+ label: "Visão Geral",
+ icon: LayoutDashboard,
+ href: "/platform",
+ description: "Estado agregado de todos os tenants, agora",
+ },
+ {
+ label: "Clientes",
+ icon: Building2,
+ children: [{ label: "Tenants" }, { label: "Planos" }, { label: "Assinaturas" }, { label: "Quotas" }],
+ },
+ {
+ label: "Billing",
+ icon: CreditCard,
+ children: [
+ { label: "Consumo" },
+ {
+ label: "Tarifas",
+ href: "/platform/billing/tarifas",
+ description: "Price books e rate decks — catálogos globais de preço",
+ },
+ { label: "Fechamentos" },
+ { label: "Relatórios" },
+ ],
+ },
+ {
+ label: "Infraestrutura",
+ icon: ServerCog,
+ children: [{ label: "FreeSWITCH" }, { label: "SIP Profiles" }, { label: "Nodes" }, { label: "Saúde" }],
+ },
+ {
+ label: "IA",
+ icon: Sparkles,
+ children: [{ label: "Providers" }, { label: "Modelos" }, { label: "Uso" }, { label: "Custos" }],
+ },
+ {
+ label: "Sistema",
+ icon: Settings2,
+ children: [{ label: "Usuários" }, { label: "Permissões" }, { label: "Auditoria" }, { label: "Configurações" }],
+ },
+];
+
+/** Título+descrição da topbar pra rota atual — casa pelo `href` mais longo
+ * (uma subrota como `/platform/billing/tarifas/price-books/123` ainda
+ * resolve pro item de menu "Tarifas"), nunca deixa a topbar com um título
+ * de outra página (bug real encontrado construindo a tela de Tarifas). */
+export function getPageMeta(pathname: string): { title: string; description?: string } {
+ let best: { href: string; title: string; description?: string } | null = null;
+
+ for (const section of PLATFORM_NAV) {
+ const candidates: Array<{ href?: string; label: string; description?: string }> = [section, ...(section.children ?? [])];
+ for (const candidate of candidates) {
+ if (!candidate.href || !pathname.startsWith(candidate.href)) continue;
+ if (!best || candidate.href.length > best.href.length) {
+ best = { href: candidate.href, title: candidate.label, description: candidate.description };
+ }
+ }
+ }
+
+ return best ?? { title: "Platform" };
+}
diff --git a/apps/frontend/src/components/platform-shell/nav-list.tsx b/apps/frontend/src/components/platform-shell/nav-list.tsx
new file mode 100644
index 0000000..cb5cc40
--- /dev/null
+++ b/apps/frontend/src/components/platform-shell/nav-list.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import { PLATFORM_NAV } from "./nav-data";
+
+/** Lista de navegação compartilhada entre a sidebar fixa (desktop, secao
+ * 166) e o drawer mobile (`MobileNavDrawer`) — uma única fonte de verdade
+ * pra estado ativo e pro "em breve" dos itens ainda não construídos. */
+export function NavList({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
+ const pathname = usePathname();
+
+ return (
+