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:
33
apps/frontend/src/lib/api.ts
Normal file
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
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
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
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
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));
|
||||
}
|
||||
Reference in New Issue
Block a user