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

65
TODO.md
View File

@@ -711,7 +711,70 @@ docs/BILLING.md
`useState`/`useMemo` puro, sem cache de query; suficiente pro
tamanho de catálogo atual
## PHASE 24+ver `agente.md` seções 140 em diante (resto do Frontend,
## PHASE 24 — Frontend: shell compartilhado, fluxo de login multi-tenant,
app do Tenant + Dashboard (agente.md secao 162, 169)
- [x] **Bug real, achado nesta fase**: o fluxo de login sempre mandava
pra `/platform`, mesmo pra um usuário que não é platform admin —
nunca chamava `/auth/tenants`/`select-tenant`. Corrigido com
`POST /api/post-login` (Route Handler server-side): decide
platform vs tenant, e no caso de 1 tenant só já troca o access
token (`select-tenant`) antes de redirecionar — o client nunca vê
token nenhum. Mais de 1 tenant vai pra `/select-tenant` (nova
página, `TenantPicker` + Server Action `chooseTenant`).
- [x] `GET /auth/me` agora inclui `tenant: {id, code, name} | null`
(nome do tenant ativo do JWT) — precisa pra topbar/sidebar do app
do tenant mostrarem de quem é a conta.
- [x] `apps/platform/layout.tsx` e `apps/app/layout.tsx` (novo) se
protegem mutuamente: platform admin sem tenant ativo cai em
`/platform`; qualquer um tentando `/platform` sem
`isPlatformUser` cai em `/app`; sem tenant ativo nenhum e sem ser
platform admin cai em `/select-tenant`.
- [x] Shell refatorado pra ser compartilhado (`components/shell/`:
`Sidebar`/`Topbar`/`MobileNavDrawer`/`NavList`/`getPageMeta`,
parametrizados por `items: NavSection[]`) — o menu Platform
(secao 168) e o menu Tenant (secao 169, `tenant-shell/nav-data.ts`)
usam a mesma implementação, só os dados mudam.
- [x] **Bug real, achado nesta fase**: passar `PLATFORM_NAV`/`TENANT_NAV`
(array com `icon: LucideIcon`, ou seja funções) como prop de um
Server Component (`layout.tsx`) pra um Client Component
(`Sidebar`/`Topbar`) quebra a serialização RSC ("Functions cannot
be passed directly to Client Components"). Corrigido com
wrappers client-only por área (`platform-sidebar.tsx`/
`platform-topbar.tsx`/`tenant-sidebar.tsx`/`tenant-topbar.tsx`)
que importam o nav-data direto (nunca recebem como prop vindo do
server) — só dados serializáveis (strings) cruzam a fronteira.
- [x] `GET /reports/dashboard` (novo, agente.md secao 162): leitura ao
vivo (não aceita `from`/`to` como o resto de `ReportsController`)
— chamadas hoje/atendidas/em andamento/esperando agente, agentes
por estado (disponível/ocupado/pausado via `AgentState`), TME/TMA/
answer rate/abandon rate do dia, "Consumo do plano" (chamadas hoje
vs `Plan.maxDailyCalls` — decisão desta implementação, a
especificação não define o que "consumo" significa aqui) e "Valor
estimado no mês" (null até existir `BillingStatement` do mês
corrente — mesma disciplina de honestidade do
`PlatformOverviewController`, nunca um valor calculado ad hoc fora
do RatingEngine).
- [x] Dashboard do tenant (`/app`) consumindo esse endpoint, mesmo
`InstrumentTile`/ghost-dash da PHASE 23.
- [x] Testado ponta a ponta com um tenant real semeado
(`admin@acme.b2bcall.local`, tenant "Acme Call Center", plano
trial): login → `/app` direto (1 tenant só); platform admin
continua indo pra `/platform` (regressão confirmada); tenant admin
tentando `/platform` é mandado de volta pra `/app`; dashboard
mostra zeros reais (nenhuma chamada/agente ainda) com os textos
`pending` corretos, "Consumo do plano" mostra `0 de 200 hoje`
batendo com `maxDailyCalls` do plano trial; drawer mobile abre e
fecha corretamente; dark mode conferido. Screenshots em
`apps/frontend/.impeccable/review/`.
- [ ] Menu Tenant inteiro (Discador/Call Center/Telefonia/Monitoramento/
Gravações/IA/Relatórios/Administração) ainda "em breve" — próxima
fase natural é Telefonia > Ramais (CRUD mais simples já testado
ponta a ponta desde a PHASE 08, mesmo padrão de tabela da Tarifas)
- [ ] "Itens sem permissão não aparecem" (secao 169) não é aplicado
ainda — a IA do tenant aparece inteira pra qualquer usuário
autenticado do tenant, independente das permissions reais dele
## PHASE 25+ — ver `agente.md` seções 140 em diante (resto do Frontend,
Security, Tests)
---

View File

@@ -55,7 +55,22 @@ export class AuthController {
select: { id: true, email: true, name: true },
});
if (!dbUser) throw new NotFoundException();
return { ...dbUser, isPlatformUser: await isPlatformUser(user.sub) };
// Nome do tenant ativo pra topbar (frontend, PHASE 23) — `tenants` não
// tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md), leitura direta
// sem `withTenantContext` é segura aqui.
const tenant = user.tenantId
? await prisma.tenant.findUnique({
where: { id: user.tenantId },
select: { id: true, code: true, tradeName: true, legalName: true },
})
: null;
return {
...dbUser,
isPlatformUser: await isPlatformUser(user.sub),
tenant: tenant ? { id: tenant.id, code: tenant.code, name: tenant.tradeName ?? tenant.legalName } : null,
};
}
@UseGuards(JwtAuthGuard)

View File

@@ -30,6 +30,75 @@ function topCounts(lists: string[][], limit: number): { value: string; count: nu
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("reports")
export class ReportsController {
/**
* Dashboard do tenant (agente.md secao 162) — leitura ao vivo, não um
* relatório de período (por isso não aceita `from`/`to`, diferente dos
* outros endpoints deste controller). "Em andamento"/"Esperando agente"
* são o estado AGORA (`endAt: null`), o resto ("hoje", TME/TMA/rates) é
* escopado ao dia corrente (UTC, mesmo corte de
* `PlatformOverviewController`).
*
* "Consumo do plano" (secao 162) é ambíguo na especificação — decisão
* desta implementação: chamadas de hoje contra `Plan.maxDailyCalls`
* (número real disponível sem nenhuma inferência), não uma cifra
* monetária. "Valor estimado no mês" seria essa cifra monetária — fica
* `null` até existir um `BillingStatement` fechado no mês corrente
* (packages/billing, PHASE 22): nunca um valor calculado ad hoc fora do
* RatingEngine (docs/BILLING.md).
*/
@RequirePermission("dashboard.view")
@Get("dashboard")
async dashboard(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
const [tenant, agentsByState, callsToday, callsAnsweredToday, callsInProgress, callsWaitingForAgent, answeredToday, abandonedToday, latestStatement] =
await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.tenant.findUniqueOrThrow({ where: { id: tenantId }, include: { plan: true } }),
tx.agent.groupBy({ by: ["state"], where: { tenantId, deletedAt: null }, _count: true }),
tx.call.count({ where: { tenantId, createdAt: { gte: todayStart } } }),
tx.call.count({ where: { tenantId, createdAt: { gte: todayStart }, agentAnswerAt: { not: null } } }),
tx.call.count({ where: { tenantId, endAt: null } }),
tx.call.count({ where: { tenantId, endAt: null, queueEnterAt: { not: null }, agentAnswerAt: null } }),
tx.call.findMany({
where: { tenantId, createdAt: { gte: todayStart }, agentAnswerAt: { not: null } },
select: { waitTime: true, talkTime: true },
}),
tx.call.count({
where: { tenantId, createdAt: { gte: todayStart }, queueEnterAt: { not: null }, agentAnswerAt: null, endAt: { not: null } },
}),
tx.billingStatement.findFirst({
where: { tenantId, billingPeriod: { periodStart: { gte: monthStart } } },
orderBy: { generatedAt: "desc" },
}),
]),
);
const stateCount = (state: string) => agentsByState.find((row) => row.state === state)?._count ?? 0;
const waitTimes = answeredToday.map((c) => c.waitTime).filter((v): v is number => v != null);
const talkTimes = answeredToday.map((c) => c.talkTime).filter((v): v is number => v != null);
return {
callsToday,
callsAnsweredToday,
callsInProgress,
callsWaitingForAgent,
agentsAvailable: stateCount("AVAILABLE"),
agentsBusy: stateCount("RESERVED") + stateCount("RINGING") + stateCount("IN_CALL") + stateCount("WRAP_UP"),
agentsPaused: stateCount("PAUSED"),
tmeSeconds: average(waitTimes),
tmaSeconds: average(talkTimes),
answerRate: callsToday > 0 ? callsAnsweredToday / callsToday : null,
abandonRate: callsToday > 0 ? abandonedToday / callsToday : null,
dailyCallQuota: { used: callsToday, max: tenant.plan.maxDailyCalls },
monthlyConsumption: latestStatement ? { amount: latestStatement.total, currency: latestStatement.currency } : null,
};
}
/** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
* um limiar configurável via query (`slThresholdSeconds`, default 20s —

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
import { getSession, sessionCookieName } from "@/lib/session";
interface Me {
isPlatformUser: boolean;
}
interface TenantMembership {
tenantId: string;
}
/**
* Decide pra onde mandar o usuário logo após `/api/login` (agente.md
* secao 31/168-169: platform admin nunca cai no app do tenant, e
* vice-versa). Roda server-side porque precisa ler o cookie de sessão
* recém-gravado e, no caso de 1 tenant só, trocar o access token por um
* já com `tenantId` nas claims (`POST /auth/select-tenant`) — o client
* nunca vê nenhum dos dois tokens.
*/
export async function POST() {
const session = await getSession();
if (!session) return NextResponse.json({ redirectTo: "/login" }, { status: 401 });
const me = await apiFetch<Me>("/auth/me", session.accessToken);
if (me.isPlatformUser) {
return NextResponse.json({ redirectTo: "/platform" });
}
const tenants = await apiFetch<TenantMembership[]>("/auth/tenants", session.accessToken);
if (tenants.length === 0) {
return NextResponse.json({ redirectTo: "/login", message: "Este usuário não tem acesso a nenhum tenant." }, { status: 403 });
}
if (tenants.length > 1) {
return NextResponse.json({ redirectTo: "/select-tenant" });
}
const { accessToken } = await apiFetch<{ accessToken: string }>("/auth/select-tenant", session.accessToken, {
method: "POST",
body: JSON.stringify({ tenantId: tenants[0].tenantId }),
});
const response = NextResponse.json({ redirectTo: "/app" });
response.cookies.set(sessionCookieName(), JSON.stringify({ accessToken, refreshToken: session.refreshToken }), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 8,
});
return response;
}

View File

@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { TenantSidebar } from "@/components/tenant-shell/tenant-sidebar";
import { TenantTopbar } from "@/components/tenant-shell/tenant-topbar";
interface Me {
id: string;
email: string;
name: string;
isPlatformUser: boolean;
tenant: { id: string; code: string; name: string } | null;
}
export default async function TenantAppLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession();
const me = await apiFetch<Me>("/auth/me", session.accessToken);
// Um usuário sem tenant ativo no token (platform admin, ou alguém com
// mais de uma membership que ainda não escolheu) nunca deveria cair
// direto aqui — /select-tenant resolve os dois casos antes de chegar.
if (!me.tenant) redirect(me.isPlatformUser ? "/platform" : "/select-tenant");
return (
<div className="flex h-dvh overflow-hidden bg-background">
<TenantSidebar railLabel={me.tenant.name} />
<div className="flex flex-1 flex-col overflow-hidden">
<TenantTopbar fallbackTitle={me.tenant.name} userLabel={me.email} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { InstrumentTile } from "@/components/ui/instrument-tile";
import { formatInt, formatDuration, formatPercent, formatCurrency } from "@/lib/format";
interface TenantDashboard {
callsToday: number;
callsAnsweredToday: number;
callsInProgress: number;
callsWaitingForAgent: number;
agentsAvailable: number;
agentsBusy: number;
agentsPaused: number;
tmeSeconds: number | null;
tmaSeconds: number | null;
answerRate: number | null;
abandonRate: number | null;
dailyCallQuota: { used: number; max: number | null };
monthlyConsumption: { amount: number; currency: string } | null;
}
export default async function TenantDashboardPage() {
const session = await requireSession();
const dashboard = await apiFetch<TenantDashboard>("/reports/dashboard", session.accessToken);
return (
<div className="space-y-6">
{/* Estado ao vivo — chamadas e agentes primeiro (agente.md secao 162) */}
<section aria-label="Estado ao vivo" className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<InstrumentTile label="Chamadas hoje" value={formatInt(dashboard.callsToday)} />
<InstrumentTile label="Atendidas" value={formatInt(dashboard.callsAnsweredToday)} />
<InstrumentTile label="Em andamento" value={formatInt(dashboard.callsInProgress)} live={dashboard.callsInProgress > 0} />
<InstrumentTile label="Esperando agente" value={formatInt(dashboard.callsWaitingForAgent)} live={dashboard.callsWaitingForAgent > 0} />
<InstrumentTile label="Agentes disponíveis" value={formatInt(dashboard.agentsAvailable)} />
<InstrumentTile label="Agentes ocupados" value={formatInt(dashboard.agentsBusy)} />
<InstrumentTile label="Agentes pausados" value={formatInt(dashboard.agentsPaused)} />
</section>
{/* Qualidade de atendimento hoje */}
<section aria-label="Qualidade de atendimento" className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<InstrumentTile
label="TME"
value={dashboard.tmeSeconds !== null ? formatDuration(dashboard.tmeSeconds) : null}
pending="Nenhuma chamada atendida hoje ainda."
/>
<InstrumentTile
label="TMA"
value={dashboard.tmaSeconds !== null ? formatDuration(dashboard.tmaSeconds) : null}
pending="Nenhuma chamada atendida hoje ainda."
/>
<InstrumentTile
label="Answer rate"
value={dashboard.answerRate !== null ? formatPercent(dashboard.answerRate) : null}
pending="Nenhuma chamada hoje ainda."
/>
<InstrumentTile
label="Abandon rate"
value={dashboard.abandonRate !== null ? formatPercent(dashboard.abandonRate) : null}
pending="Nenhuma chamada hoje ainda."
/>
</section>
{/* Consumo do plano / valor estimado (agente.md secao 162) */}
<section aria-label="Consumo do plano" className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InstrumentTile
label="Consumo do plano"
value={formatInt(dashboard.dailyCallQuota.used)}
unit="chamadas"
suffix={dashboard.dailyCallQuota.max !== null ? `de ${formatInt(dashboard.dailyCallQuota.max)} hoje` : "sem limite diário"}
/>
<InstrumentTile
label="Valor estimado no mês"
value={dashboard.monthlyConsumption ? formatCurrency(dashboard.monthlyConsumption.amount, dashboard.monthlyConsumption.currency) : null}
pending="Nenhum período de billing foi fechado ainda neste mês — sem número real pra mostrar (nunca um valor inventado)."
/>
</section>
</div>
);
}

View File

@@ -27,7 +27,16 @@ export default function LoginPage() {
setError(body.message ?? "Não foi possível entrar.");
return;
}
router.push("/platform");
// Decide platform vs tenant (e qual tenant) server-side — nunca no
// client, o token de sessão não é legível por JS (ver post-login/route.ts).
const postLogin = await fetch("/api/post-login", { method: "POST" });
const postLoginBody = await postLogin.json().catch(() => ({ redirectTo: "/login" }));
if (!postLogin.ok) {
setError(postLoginBody.message ?? "Não foi possível entrar.");
return;
}
router.push(postLoginBody.redirectTo);
router.refresh();
} catch {
setError("Não foi possível entrar. Verifique sua conexão.");

View File

@@ -1,7 +1,8 @@
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { PlatformSidebar } from "@/components/platform-shell/sidebar";
import { PlatformTopbar } from "@/components/platform-shell/topbar";
import { PlatformSidebar } from "@/components/platform-shell/platform-sidebar";
import { PlatformTopbar } from "@/components/platform-shell/platform-topbar";
interface Me {
id: string;
@@ -13,12 +14,13 @@ interface Me {
export default async function PlatformLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession();
const me = await apiFetch<Me>("/auth/me", session.accessToken);
if (!me.isPlatformUser) redirect("/app");
return (
<div className="flex h-dvh overflow-hidden bg-background">
<PlatformSidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<PlatformTopbar userEmail={me.email} />
<PlatformTopbar userLabel={me.email} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>

View File

@@ -0,0 +1,33 @@
"use server";
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { sessionCookieName } from "@/lib/session";
import { cookies } from "next/headers";
export async function chooseTenant(tenantId: string): Promise<{ error: string } | void> {
const session = await requireSession();
let accessToken: string;
try {
const result = await apiFetch<{ accessToken: string }>("/auth/select-tenant", session.accessToken, {
method: "POST",
body: JSON.stringify({ tenantId }),
});
accessToken = result.accessToken;
} catch {
return { error: "Não foi possível entrar nesse tenant. Tente novamente." };
}
const store = await cookies();
store.set(sessionCookieName(), JSON.stringify({ accessToken, refreshToken: session.refreshToken }), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 8,
});
redirect("/app");
}

View File

@@ -0,0 +1,29 @@
import Image from "next/image";
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import { TenantPicker } from "./picker";
interface TenantOption {
tenantId: string;
code: string;
name: string;
status: string;
}
export default async function SelectTenantPage() {
const session = await requireSession();
const tenants = await apiFetch<TenantOption[]>("/auth/tenants", session.accessToken);
return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12">
<div className="w-full max-w-sm">
<Image src="/branding/b2blogo.png" alt="B2BCall" width={140} height={34} priority className="mx-auto mb-8" />
<h1 className="text-center text-lg font-semibold text-foreground">Qual conta você quer acessar?</h1>
<p className="mt-1 text-center text-sm text-muted-foreground">Sua conta tem acesso a mais de um tenant.</p>
<div className="mt-6">
<TenantPicker tenants={tenants} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useState, useTransition } from "react";
import { Building2, ChevronRight } from "lucide-react";
import { Panel } from "@/components/ui/panel";
import { chooseTenant } from "./actions";
interface TenantOption {
tenantId: string;
name: string;
code: string;
status: string;
}
export function TenantPicker({ tenants }: { tenants: TenantOption[] }) {
const [error, setError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [, startTransition] = useTransition();
function onSelect(tenantId: string) {
setError(null);
setPendingId(tenantId);
startTransition(async () => {
const result = await chooseTenant(tenantId);
if (result?.error) {
setError(result.error);
setPendingId(null);
}
});
}
return (
<div className="space-y-3">
<Panel>
<ul className="divide-y divide-border">
{tenants.map((tenant) => (
<li key={tenant.tenantId}>
<button
type="button"
onClick={() => onSelect(tenant.tenantId)}
disabled={pendingId !== null}
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted disabled:opacity-50"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Building2 className="h-4 w-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{tenant.name}</span>
<span className="block truncate text-xs text-muted-foreground">{tenant.code}</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
</li>
))}
</ul>
</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>
);
}

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} />;
}

View File

@@ -2,6 +2,18 @@ export function formatInt(n: number): string {
return new Intl.NumberFormat("pt-BR").format(Math.round(n));
}
/** mm:ss a partir de segundos — usado por TME/TMA (agente.md secao 162). */
export function formatDuration(seconds: number): string {
const total = Math.round(seconds);
const mm = Math.floor(total / 60);
const ss = total % 60;
return `${mm}:${String(ss).padStart(2, "0")}`;
}
export function formatPercent(ratio: number): string {
return new Intl.NumberFormat("pt-BR", { style: "percent", maximumFractionDigits: 1 }).format(ratio);
}
export function formatBytes(bytes: number): { value: string; unit: string } {
if (bytes === 0) return { value: "0", unit: "B" };
const units = ["B", "KB", "MB", "GB", "TB"];