feat(frontend): app do tenant, dashboard ao vivo, fluxo de login multi-tenant

Corrige um gap real: o login sempre mandava pra /platform mesmo pra
usuarios sem role de plataforma, sem nunca chamar /auth/tenants ou
select-tenant. Agora POST /api/post-login decide o destino server-side
(plataforma / tenant unico / seletor com >1 tenant) antes de redirecionar,
trocando o access token quando necessario sem nunca expor token ao client.

Adiciona GET /reports/dashboard (secao 162) e a tela /app correspondente
— chamadas/agentes/TME/TMA/rates ao vivo, "consumo do plano" e "valor
estimado" seguindo a mesma disciplina de honestidade (null > numero
inventado) do dashboard de plataforma.

Shell (sidebar/topbar/drawer mobile) extraido pra components/shell,
compartilhado entre os menus Platform e Tenant (secao 168-169) via
wrappers client-only por area — corrige de quebra um erro real de
serializacao RSC (passar NavSection[] com icones como prop de Server
Component pra Client Component quebra: "Functions cannot be passed
directly to Client Components").

Testado ponta a ponta com um tenant semeado (Acme Call Center): login →
/app direto, platform admin barrado de /app e vice-versa, dashboard com
numeros reais (zeros honestos), drawer mobile, dark mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 09:30:16 -03:00
parent aad16d136e
commit 565110eac3
28 changed files with 662 additions and 67 deletions

View File

@@ -1,27 +1,5 @@
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[];
}
import { LayoutDashboard, Building2, CreditCard, ServerCog, Sparkles, Settings2 } from "lucide-react";
import type { NavSection } from "../shell/nav-types";
/** 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
@@ -73,23 +51,3 @@ export const PLATFORM_NAV: NavSection[] = [
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" };
}

View File

@@ -0,0 +1,17 @@
"use client";
import { Sidebar } from "@/components/shell/sidebar";
import { PLATFORM_NAV } from "./nav-data";
/**
* Wrapper client-only — `PLATFORM_NAV` carrega componentes de ícone
* (funções), que não são serializáveis através da fronteira RSC. O
* `layout.tsx` (Server Component) só pode passar pro `Sidebar` genérico
* (client) um valor que já nasceu no bundle client; importar `nav-data`
* aqui dentro (em vez de receber `items` como prop vindo do server)
* resolve isso. Bug real, achado testando o app do tenant: "Functions
* cannot be passed directly to Client Components".
*/
export function PlatformSidebar() {
return <Sidebar items={PLATFORM_NAV} railLabel="PLATFORM" />;
}

View File

@@ -0,0 +1,9 @@
"use client";
import { Topbar } from "@/components/shell/topbar";
import { PLATFORM_NAV } from "./nav-data";
/** Mesma razão do `PlatformSidebar` — ver comentário lá. */
export function PlatformTopbar({ userLabel }: { userLabel: string }) {
return <Topbar items={PLATFORM_NAV} fallbackTitle="Platform" userLabel={userLabel} />;
}

View File

@@ -5,13 +5,14 @@ import Image from "next/image";
import * as Dialog from "@radix-ui/react-dialog";
import { Menu, X } from "lucide-react";
import { NavList } from "./nav-list";
import type { NavSection } from "./nav-types";
/** 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 foco preso + Escape + clique fora de graça (secao 176:
* navegação completa por teclado). */
export function MobileNavDrawer() {
export function MobileNavDrawer({ items }: { items: NavSection[] }) {
const [open, setOpen] = useState(false);
return (
@@ -31,7 +32,7 @@ export function MobileNavDrawer() {
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>
<Dialog.Title className="sr-only">Navegação</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>
@@ -45,7 +46,7 @@ export function MobileNavDrawer() {
</Dialog.Close>
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
<NavList onNavigate={() => setOpen(false)} />
<NavList items={items} onNavigate={() => setOpen(false)} />
</nav>
</Dialog.Content>
</Dialog.Portal>

View File

@@ -3,17 +3,18 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { PLATFORM_NAV } from "./nav-data";
import type { NavSection } from "./nav-types";
/** 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 }) {
* 166) e o drawer mobile (`MobileNavDrawer`) a mesma implementação
* serve o menu Platform e o menu Tenant (secao 168-169), os dados
* (`items`) mudam. */
export function NavList({ items, collapsed = false, onNavigate }: { items: NavSection[]; collapsed?: boolean; onNavigate?: () => void }) {
const pathname = usePathname();
return (
<ul className="space-y-0.5">
{PLATFORM_NAV.map((section) => {
{items.map((section) => {
const Icon = section.icon;
const active = section.href && pathname === section.href;
@@ -36,6 +37,28 @@ export function NavList({ collapsed = false, onNavigate }: { collapsed?: boolean
);
}
// Item de página única ainda não construído (ex.: "Gravações",
// secao 169 — sem sub-itens na especificação) — mesmo tratamento
// "em breve" de um leaf, só que direto no nível de seção.
if (!section.children) {
return (
<li key={section.label} className="pt-2">
<span
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground/50"
title="Ainda não construído nesta passada"
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
{!collapsed && (
<>
<span className="flex-1">{section.label}</span>
<span className="text-[10px] font-medium uppercase tracking-wide">em breve</span>
</>
)}
</span>
</li>
);
}
const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href));
return (

View File

@@ -0,0 +1,36 @@
import type { LucideIcon } 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[];
}
/** Título+descrição da topbar pra rota atual — casa pelo `href` mais longo
* (uma subrota como `/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(items: NavSection[], pathname: string, fallbackTitle: string): { title: string; description?: string } {
let best: { href: string; title: string; description?: string } | null = null;
for (const section of items) {
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: fallbackTitle };
}

View File

@@ -4,12 +4,13 @@ import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { NavList } from "./nav-list";
import type { NavSection } from "./nav-types";
/** 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() {
export function Sidebar({ items, railLabel }: { items: NavSection[]; railLabel: string }) {
const [collapsed, setCollapsed] = useState(false);
return (
@@ -20,7 +21,7 @@ export function PlatformSidebar() {
)}
>
<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>}
{!collapsed && <span className="text-sm font-semibold tracking-wide text-foreground">{railLabel}</span>}
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
@@ -33,7 +34,7 @@ export function PlatformSidebar() {
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
<NavList collapsed={collapsed} />
<NavList items={items} collapsed={collapsed} />
</nav>
</aside>
);

View File

@@ -2,14 +2,14 @@
import { useRouter, usePathname } from "next/navigation";
import { LogOut } from "lucide-react";
import { ThemeToggle } from "./theme-toggle";
import { ThemeToggle } from "../platform-shell/theme-toggle";
import { MobileNavDrawer } from "./mobile-nav-drawer";
import { getPageMeta } from "./nav-data";
import { getPageMeta, type NavSection } from "./nav-types";
export function PlatformTopbar({ userEmail }: { userEmail: string }) {
export function Topbar({ items, fallbackTitle, userLabel }: { items: NavSection[]; fallbackTitle: string; userLabel: string }) {
const router = useRouter();
const pathname = usePathname();
const { title, description } = getPageMeta(pathname);
const { title, description } = getPageMeta(items, pathname, fallbackTitle);
async function onLogout() {
await fetch("/api/logout", { method: "POST" });
@@ -20,7 +20,7 @@ export function PlatformTopbar({ userEmail }: { userEmail: string }) {
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 />
<MobileNavDrawer items={items} />
<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>}
@@ -29,7 +29,7 @@ export function PlatformTopbar({ userEmail }: { userEmail: string }) {
<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>
<span className="hidden max-w-[220px] truncate text-sm text-muted-foreground sm:inline">{userLabel}</span>
<button
type="button"
onClick={onLogout}

View File

@@ -0,0 +1,70 @@
import {
LayoutDashboard,
PhoneOutgoing,
Headset,
Router,
Radar,
Mic,
Sparkles,
BarChart3,
ShieldCheck,
} from "lucide-react";
import type { NavSection } from "../shell/nav-types";
/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" tem página
* construída até agora (PHASE 23), o resto existe pra provar a
* arquitetura completa (Product Principle #5 em PRODUCT.md), renderizado
* como indisponível em vez de virar link morto. "Itens sem permissão não
* aparecem" (secao 169) ainda não é aplicado aqui — este primeiro corte
* mostra a IA inteira pra qualquer usuário tenant autenticado; filtrar por
* permission real fica pra quando mais telas existirem pra testar contra. */
export const TENANT_NAV: NavSection[] = [
{
label: "Dashboard",
icon: LayoutDashboard,
href: "/app",
description: "Chamadas, agentes e filas agora",
},
{
label: "Discador",
icon: PhoneOutgoing,
children: [
{ label: "Campanhas" },
{ label: "Leads" },
{ label: "Importações" },
{ label: "Callbacks" },
{ label: "Lista de Bloqueio" },
],
},
{
label: "Call Center",
icon: Headset,
children: [{ label: "Agentes" }, { label: "Filas" }, { label: "Pausas" }, { label: "Disposições" }],
},
{
label: "Telefonia",
icon: Router,
children: [{ label: "Ramais" }, { label: "Troncos" }, { label: "Dialplan" }],
},
{
label: "Monitoramento",
icon: Radar,
children: [{ label: "Campanhas" }, { label: "Filas" }, { label: "Agentes" }, { label: "Ramais" }, { label: "Troncos" }],
},
{ label: "Gravações", icon: Mic },
{
label: "IA",
icon: Sparkles,
children: [{ label: "Análises" }, { label: "Scorecards" }, { label: "Prompts" }, { label: "Configurações" }],
},
{
label: "Relatórios",
icon: BarChart3,
children: [{ label: "Chamadas" }, { label: "Agentes" }, { label: "Filas" }, { label: "Campanhas" }, { label: "Consumo" }],
},
{
label: "Administração",
icon: ShieldCheck,
children: [{ label: "Usuários" }, { label: "Perfis" }, { label: "Configurações" }],
},
];

View File

@@ -0,0 +1,10 @@
"use client";
import { Sidebar } from "@/components/shell/sidebar";
import { TENANT_NAV } from "./nav-data";
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão
* (ícones não são serializáveis através da fronteira RSC). */
export function TenantSidebar({ railLabel }: { railLabel: string }) {
return <Sidebar items={TENANT_NAV} railLabel={railLabel} />;
}

View File

@@ -0,0 +1,9 @@
"use client";
import { Topbar } from "@/components/shell/topbar";
import { TENANT_NAV } from "./nav-data";
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
export function TenantTopbar({ fallbackTitle, userLabel }: { fallbackTitle: string; userLabel: string }) {
return <Topbar items={TENANT_NAV} fallbackTitle={fallbackTitle} userLabel={userLabel} />;
}