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:
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 dá 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>
|
||||
@@ -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), só 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 (
|
||||
36
apps/frontend/src/components/shell/nav-types.ts
Normal file
36
apps/frontend/src/components/shell/nav-types.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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}
|
||||
70
apps/frontend/src/components/tenant-shell/nav-data.ts
Normal file
70
apps/frontend/src/components/tenant-shell/nav-data.ts
Normal 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" }],
|
||||
},
|
||||
];
|
||||
10
apps/frontend/src/components/tenant-shell/tenant-sidebar.tsx
Normal file
10
apps/frontend/src/components/tenant-shell/tenant-sidebar.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user