feat(frontend): app shell + login + platform dashboard + billing tarifas
Primeiro commit do frontend Next.js (agente.md secao 161-176): login split-brand, dashboard "Visão Geral da Plataforma" com instrumentos ao vivo, e a tela Billing > Tarifas (price books + rate decks) completa — listagem com busca/ordenacao, criacao com itens/entradas dinamicos via Server Actions, detalhe — ponta a ponta contra a API real de billing (fase 22). Corrige de quebra 2 bugs reais achados construindo Tarifas: a topbar tinha o titulo fixo "Visao Geral" em toda pagina, e a sidebar fixa de 256px nao tinha nenhuma versao mobile (conteudo espremido em ~130px) — agora vira drawer via Radix Dialog abaixo de lg, com titulo/descricao da topbar resolvidos dinamicamente por rota. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
38
apps/frontend/src/app/api/login/route.ts
Normal file
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
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
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
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
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
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
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
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
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
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
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user