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
4
.gitignore
vendored
@@ -15,6 +15,10 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.next/
|
.next/
|
||||||
.turbo/
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Impeccable per-developer overrides (see .claude/skills/impeccable)
|
||||||
|
.impeccable/config.local.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/dashboard-desktop.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/frontend/.impeccable/review/dashboard-mobile.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
apps/frontend/.impeccable/review/price-book-detail-desktop.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/frontend/.impeccable/review/price-book-new-dark-desktop.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/frontend/.impeccable/review/price-book-new-desktop.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/frontend/.impeccable/review/price-book-new-mobile.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/frontend/.impeccable/review/rate-deck-new-desktop.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/frontend/.impeccable/review/rate-deck-new-mobile.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/frontend/.impeccable/review/tarifas-dark-desktop.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/frontend/.impeccable/review/tarifas-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
apps/frontend/.impeccable/review/tarifas-empty-mobile.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/frontend/.impeccable/review/tarifas-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
66
apps/frontend/PRODUCT.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
5
apps/frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
15
apps/frontend/next.config.ts
Normal file
@@ -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;
|
||||||
38
apps/frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
38
apps/frontend/src/app/api/login/route.ts
Normal 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;
|
||||||
|
}
|
||||||
8
apps/frontend/src/app/api/logout/route.ts
Normal 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;
|
||||||
|
}
|
||||||
126
apps/frontend/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/frontend/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/frontend/src/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/frontend/src/app/page.tsx
Normal 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");
|
||||||
|
}
|
||||||
88
apps/frontend/src/app/platform/billing/tarifas/actions.ts
Normal 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");
|
||||||
|
}
|
||||||
14
apps/frontend/src/app/platform/billing/tarifas/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
apps/frontend/src/app/platform/billing/tarifas/tarifas-view.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/frontend/src/app/platform/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/frontend/src/app/platform/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Abrir menu de navegação"
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-40 bg-[hsl(224_45%_11%)]/50 opacity-0 backdrop-blur-[1px] transition-opacity duration-200 data-[state=open]:opacity-100" />
|
||||||
|
<Dialog.Content
|
||||||
|
className="fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] -translate-x-full flex-col bg-surface shadow-pop outline-none transition-transform duration-200 ease-out data-[state=open]:translate-x-0"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
<Dialog.Title className="sr-only">Navegação da plataforma</Dialog.Title>
|
||||||
|
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||||
|
<Image src="/branding/b2blogo.png" alt="B2BCall" width={110} height={27} />
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Fechar menu"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
|
||||||
|
<NavList onNavigate={() => setOpen(false)} />
|
||||||
|
</nav>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/frontend/src/components/platform-shell/nav-data.ts
Normal file
@@ -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" };
|
||||||
|
}
|
||||||
94
apps/frontend/src/components/platform-shell/nav-list.tsx
Normal file
@@ -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 (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{PLATFORM_NAV.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
const active = section.href && pathname === section.href;
|
||||||
|
|
||||||
|
if (section.href) {
|
||||||
|
return (
|
||||||
|
<li key={section.label}>
|
||||||
|
<Link
|
||||||
|
href={section.href}
|
||||||
|
onClick={onNavigate}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
active ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
{!collapsed && <span>{section.label}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={section.label} className="pt-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide",
|
||||||
|
sectionHasActiveChild ? "text-foreground" : "text-muted-foreground/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
{!collapsed && <span>{section.label}</span>}
|
||||||
|
</div>
|
||||||
|
{!collapsed && section.children && (
|
||||||
|
<ul className="ml-[26px] space-y-0.5 border-l border-border pl-3">
|
||||||
|
{section.children.map((leaf) => {
|
||||||
|
const leafActive = leaf.href && pathname.startsWith(leaf.href);
|
||||||
|
|
||||||
|
if (leaf.href) {
|
||||||
|
return (
|
||||||
|
<li key={leaf.label}>
|
||||||
|
<Link
|
||||||
|
href={leaf.href}
|
||||||
|
onClick={onNavigate}
|
||||||
|
aria-current={leafActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
leafActive ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leaf.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={leaf.label}>
|
||||||
|
<span
|
||||||
|
className="flex cursor-not-allowed items-center justify-between rounded-md px-2 py-1.5 text-sm text-muted-foreground/50"
|
||||||
|
title="Ainda não construído nesta passada"
|
||||||
|
>
|
||||||
|
{leaf.label}
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide">em breve</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/frontend/src/components/platform-shell/sidebar.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { NavList } from "./nav-list";
|
||||||
|
|
||||||
|
/** Sidebar fixa (secao 166) — só em telas lg+; abaixo disso vira drawer
|
||||||
|
* (`MobileNavDrawer`, acionado pela topbar) porque uma coluna de 256px
|
||||||
|
* fixa não cabe num viewport de celular (bug real achado construindo a
|
||||||
|
* tela de Tarifas: o conteúdo ficava espremido em ~130px). */
|
||||||
|
export function PlatformSidebar() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"hidden h-dvh shrink-0 flex-col border-r border-border bg-surface transition-[width] duration-200 lg:flex",
|
||||||
|
collapsed ? "w-[68px]" : "w-64",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||||
|
{!collapsed && <span className="text-sm font-semibold tracking-wide text-foreground">PLATFORM</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
aria-label={collapsed ? "Expandir menu" : "Recolher menu"}
|
||||||
|
aria-pressed={collapsed}
|
||||||
|
className="ml-auto flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
|
||||||
|
<NavList collapsed={collapsed} />
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/frontend/src/components/platform-shell/theme-toggle.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Sun, Moon, Monitor } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const OPTIONS: { value: Theme; label: string; icon: typeof Sun }[] = [
|
||||||
|
{ value: "light", label: "Claro", icon: Sun },
|
||||||
|
{ value: "dark", label: "Escuro", icon: Moon },
|
||||||
|
{ value: "system", label: "Sistema", icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Light/Dark/System (agente.md secao 174). "system" = nenhuma classe (a
|
||||||
|
* media query em globals.css decide); light/dark gravam a classe em
|
||||||
|
* <html> e persistem em localStorage, lidas de novo pelo script síncrono
|
||||||
|
* em layout.tsx antes do primeiro paint. */
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setThemeState] = useState<Theme>("system");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("b2bcall-theme");
|
||||||
|
if (stored === "light" || stored === "dark") setThemeState(stored);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function setTheme(next: Theme) {
|
||||||
|
setThemeState(next);
|
||||||
|
document.documentElement.classList.remove("light", "dark");
|
||||||
|
if (next === "system") {
|
||||||
|
localStorage.removeItem("b2bcall-theme");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add(next);
|
||||||
|
localStorage.setItem("b2bcall-theme", next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label="Tema" className="flex items-center rounded-md border border-border bg-muted p-0.5">
|
||||||
|
{OPTIONS.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={theme === value}
|
||||||
|
title={label}
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-7 items-center justify-center rounded-[5px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
theme === value ? "bg-surface text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/frontend/src/components/platform-shell/topbar.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { MobileNavDrawer } from "./mobile-nav-drawer";
|
||||||
|
import { getPageMeta } from "./nav-data";
|
||||||
|
|
||||||
|
export function PlatformTopbar({ userEmail }: { userEmail: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { title, description } = getPageMeta(pathname);
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await fetch("/api/logout", { method: "POST" });
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border bg-surface px-4 sm:px-6">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<MobileNavDrawer />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">{title}</p>
|
||||||
|
{description && <p className="truncate text-xs text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 sm:gap-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
<div className="hidden h-6 w-px bg-border sm:block" aria-hidden />
|
||||||
|
<span className="hidden text-sm text-muted-foreground sm:inline">{userEmail}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" aria-hidden />
|
||||||
|
<span className="hidden sm:inline">Sair</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||||
|
outline: "border border-border bg-transparent hover:bg-muted",
|
||||||
|
ghost: "hover:bg-muted",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4",
|
||||||
|
sm: "h-8 px-3 text-xs",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
40
apps/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md border border-input bg-surface px-3 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md border border-input bg-surface px-3 text-sm text-foreground outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
export function FieldLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||||
|
return (
|
||||||
|
<label htmlFor={htmlFor} className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/frontend/src/components/ui/instrument-tile.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface InstrumentTileProps {
|
||||||
|
label: string;
|
||||||
|
value: number | string | null;
|
||||||
|
unit?: string;
|
||||||
|
live?: boolean;
|
||||||
|
/** Segundo valor pequeno ao lado (ex.: "de 10" pra 8/10) — nunca outra
|
||||||
|
* cor por conta própria, herda a mesma leitura neutra do label. */
|
||||||
|
suffix?: string;
|
||||||
|
pending?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitura instrumento/painel (direção desta build — ver comentário no
|
||||||
|
* layout raiz): números grandes, monoespaçados, tabulares, como um
|
||||||
|
* mostrador de equipamento de telefonia. `value === null` não vira "0"
|
||||||
|
* nem some — vira um traço fantasma (segmento apagado, não inexistente),
|
||||||
|
* com `pending` explicando por que ainda não há número real (agente.md
|
||||||
|
* secao 138: nunca fingir uma estimativa como se fosse número fechado).
|
||||||
|
*/
|
||||||
|
export function InstrumentTile({ label, value, unit, live, suffix, pending }: InstrumentTileProps) {
|
||||||
|
const isGhost = value === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col justify-between rounded-lg border border-border bg-surface px-5 py-4 shadow-panel">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
|
{live && !isGhost && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-status-green animate-pulse-live" aria-hidden />
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-status-green">live</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-baseline gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-4xl font-semibold tabular-nums leading-none",
|
||||||
|
isGhost ? "text-muted-foreground/30" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isGhost ? "– –" : value}
|
||||||
|
</span>
|
||||||
|
{unit && !isGhost && <span className="font-mono text-sm text-muted-foreground">{unit}</span>}
|
||||||
|
{suffix && !isGhost && <span className="ml-1 text-sm text-muted-foreground">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGhost && pending && <p className="mt-2 text-[11px] leading-snug text-muted-foreground">{pending}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/frontend/src/components/ui/panel.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Panel({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-lg border border-border bg-surface shadow-panel", className)}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelHeader({ title, description }: { title: string; description?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border px-5 py-4">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||||
|
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/frontend/src/components/ui/pill.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Marcador neutro genérico (contagens, "padrão") — nunca usado pra status
|
||||||
|
* de domínio (campanha, período de billing etc.), que sempre passa por
|
||||||
|
* `StatusBadge` com forma + cor + rótulo (secao 176). */
|
||||||
|
export function Pill({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "neutral" | "accent" }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||||
|
tone === "accent"
|
||||||
|
? "border-accent/30 bg-accent/10 text-accent"
|
||||||
|
: "border-border bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/frontend/src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badges de status (agente.md secao 172): mapeamento de cor fixo por
|
||||||
|
* status de campanha. Secao 176 (acessibilidade): "Status não podem
|
||||||
|
* depender apenas da cor" — por isso sempre um marcador de forma (ponto
|
||||||
|
* cheio/vazio/quadrado) ALÉM da cor e do texto, nunca só um chip colorido.
|
||||||
|
*/
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; color: string; shape: "dot" | "ring" | "square" }> = {
|
||||||
|
DRAFT: { label: "Rascunho", color: "status-gray", shape: "ring" },
|
||||||
|
READY: { label: "Pronta", color: "status-blue", shape: "ring" },
|
||||||
|
RUNNING: { label: "Em execução", color: "status-green", shape: "dot" },
|
||||||
|
PAUSED: { label: "Pausada", color: "status-yellow", shape: "square" },
|
||||||
|
DRAINING: { label: "Drenando", color: "status-orange", shape: "square" },
|
||||||
|
STOPPED: { label: "Parada", color: "status-red", shape: "dot" },
|
||||||
|
COMPLETED: { label: "Concluída", color: "status-green-dark", shape: "dot" },
|
||||||
|
ERROR: { label: "Erro", color: "status-red", shape: "square" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHAPE_CLASS: Record<string, string> = {
|
||||||
|
dot: "rounded-full",
|
||||||
|
ring: "rounded-full border-2 bg-transparent!",
|
||||||
|
square: "rounded-[2px]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const config = STATUS_CONFIG[status] ?? { label: status, color: "status-gray", shape: "ring" as const };
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-2.5 py-1 text-xs font-medium text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("h-2 w-2 shrink-0", SHAPE_CLASS[config.shape])}
|
||||||
|
style={{
|
||||||
|
backgroundColor: config.shape === "ring" ? "transparent" : `hsl(var(--${config.color}))`,
|
||||||
|
borderColor: `hsl(var(--${config.color}))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
apps/frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Table({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-sm">{children}</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function THead({ children }: { children: React.ReactNode }) {
|
||||||
|
return <thead className="border-b border-border">{children}</thead>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TBody({ children }: { children: React.ReactNode }) {
|
||||||
|
return <tbody className="divide-y divide-border">{children}</tbody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TR({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(onClick && "cursor-pointer transition-colors hover:bg-muted/60", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TH({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
"aria-sort": ariaSort,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
"aria-sort"?: React.AriaAttributes["aria-sort"];
|
||||||
|
}) {
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
aria-sort={ariaSort}
|
||||||
|
className={cn("px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground", className)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<th scope="col" className={cn("px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground", className)}>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TD({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <td className={cn("px-4 py-3 align-middle text-foreground", className)}>{children}</td>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1 px-6 py-16 text-center">
|
||||||
|
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||||
|
{description && <p className="max-w-sm text-sm text-muted-foreground">{description}</p>}
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const API_BASE_URL = process.env.B2BCALL_API_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chama a API real (NestJS/Fastify) do servidor (Server Component/Route
|
||||||
|
* Handler) — nunca do client, o access token nunca sai como JS legível
|
||||||
|
* (fica só no cookie httpOnly, ver session.ts). */
|
||||||
|
export async function apiFetch<T>(path: string, accessToken: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...init?.headers,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new ApiError(res.status, body || res.statusText);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
39
apps/frontend/src/lib/billing-types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export interface PriceBookItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
unitPrice: number;
|
||||||
|
effectiveFrom: string;
|
||||||
|
effectiveUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceBook {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
items: PriceBookItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateDeckEntry {
|
||||||
|
id: string;
|
||||||
|
prefix: string;
|
||||||
|
destinationName: string;
|
||||||
|
destinationType: string;
|
||||||
|
pricePerMinute: number;
|
||||||
|
billingIncrementSeconds: number;
|
||||||
|
minimumSeconds: number;
|
||||||
|
connectionFee: number;
|
||||||
|
validFrom: string;
|
||||||
|
validUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateDeck {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
entries: RateDeckEntry[];
|
||||||
|
}
|
||||||
56
apps/frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export function formatInt(n: number): string {
|
||||||
|
return new Intl.NumberFormat("pt-BR").format(Math.round(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): { value: string; unit: string } {
|
||||||
|
if (bytes === 0) return { value: "0", unit: "B" };
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1000)), units.length - 1);
|
||||||
|
const value = bytes / 1000 ** exp;
|
||||||
|
return { value: value.toFixed(exp === 0 ? 0 : 1), unit: units[exp] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_USAGE_LABELS: Record<string, string> = {
|
||||||
|
AI_TRANSCRIPTION_SECONDS: "Transcrição (s)",
|
||||||
|
AI_ANALYSIS_REQUEST: "Análises",
|
||||||
|
AI_INPUT_TOKENS: "Tokens de entrada",
|
||||||
|
AI_OUTPUT_TOKENS: "Tokens de saída",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatCurrency(value: number, currency = "BRL"): string {
|
||||||
|
return new Intl.NumberFormat("pt-BR", { style: "currency", currency, maximumFractionDigits: 6 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short", year: "numeric" }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** agente.md secao 128 — catálogo de preço, mesma nomenclatura do
|
||||||
|
* `PriceItemType` do backend (packages/database/prisma/schema.prisma). */
|
||||||
|
export const PRICE_ITEM_TYPE_LABELS: Record<string, string> = {
|
||||||
|
BASE_SUBSCRIPTION: "Assinatura base",
|
||||||
|
EXTENSION_MONTH: "Ramal / mês",
|
||||||
|
AGENT_MONTH: "Agente / mês",
|
||||||
|
TRUNK_MONTH: "Tronco / mês",
|
||||||
|
CALL: "Chamada (fixo)",
|
||||||
|
CALL_MINUTE: "Minuto de chamada (padrão)",
|
||||||
|
FIXED_MINUTE: "Minuto — fixo",
|
||||||
|
MOBILE_MINUTE: "Minuto — móvel",
|
||||||
|
INTERNATIONAL_MINUTE: "Minuto — internacional",
|
||||||
|
AI_TRANSCRIPTION_MINUTE: "IA — transcrição / minuto",
|
||||||
|
AI_ANALYSIS_CALL: "IA — análise / chamada",
|
||||||
|
AI_INPUT_TOKEN: "IA — token de entrada",
|
||||||
|
AI_OUTPUT_TOKEN: "IA — token de saída",
|
||||||
|
RECORDING_GB_MONTH: "Armazenamento / GB / mês",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRICE_ITEM_TYPES = Object.keys(PRICE_ITEM_TYPE_LABELS) as Array<keyof typeof PRICE_ITEM_TYPE_LABELS>;
|
||||||
|
|
||||||
|
/** agente.md secao 129 — tipos de destino de um RateDeckEntry. */
|
||||||
|
export const DESTINATION_TYPE_LABELS: Record<string, string> = {
|
||||||
|
FIXED: "Fixo",
|
||||||
|
MOBILE: "Móvel",
|
||||||
|
INTERNATIONAL: "Internacional",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DESTINATION_TYPES = Object.keys(DESTINATION_TYPE_LABELS) as Array<keyof typeof DESTINATION_TYPE_LABELS>;
|
||||||
33
apps/frontend/src/lib/session.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "b2bcall_session";
|
||||||
|
|
||||||
|
export interface SessionCookie {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<SessionCookie | null> {
|
||||||
|
const store = await cookies();
|
||||||
|
const raw = store.get(COOKIE_NAME)?.value;
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as SessionCookie;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server Component guard — sem sessão, manda pro login (nunca renderiza
|
||||||
|
* a página protegida "meio autenticada"). */
|
||||||
|
export async function requireSession(): Promise<SessionCookie> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieName(): string {
|
||||||
|
return COOKIE_NAME;
|
||||||
|
}
|
||||||
6
apps/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
72
apps/frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
DEFAULT: "hsl(var(--surface))",
|
||||||
|
raised: "hsl(var(--surface-raised))",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
gray: "hsl(var(--status-gray))",
|
||||||
|
blue: "hsl(var(--status-blue))",
|
||||||
|
green: "hsl(var(--status-green))",
|
||||||
|
yellow: "hsl(var(--status-yellow))",
|
||||||
|
orange: "hsl(var(--status-orange))",
|
||||||
|
red: "hsl(var(--status-red))",
|
||||||
|
"green-dark": "hsl(var(--status-green-dark))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 3px)",
|
||||||
|
sm: "calc(var(--radius) - 6px)",
|
||||||
|
xl: "calc(var(--radius) + 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)", "IBM Plex Sans", "system-ui", "sans-serif"],
|
||||||
|
mono: ["var(--font-mono)", "IBM Plex Mono", "ui-monospace", "monospace"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
panel: "0 1px 2px hsl(var(--shadow-color) / 0.06), 0 8px 24px -8px hsl(var(--shadow-color) / 0.18)",
|
||||||
|
pop: "0 4px 12px hsl(var(--shadow-color) / 0.12), 0 16px 40px -12px hsl(var(--shadow-color) / 0.28)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"pulse-live": {
|
||||||
|
"0%, 100%": { opacity: "1" },
|
||||||
|
"50%": { opacity: "0.45" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"pulse-live": "pulse-live 2.4s ease-in-out infinite",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
23
apps/frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||