diff --git a/TODO.md b/TODO.md index 91a1fca..210e1b7 100644 --- a/TODO.md +++ b/TODO.md @@ -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) --- diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index ec09a89..44dae3d 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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) diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index 4ced383..86162c2 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -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 — diff --git a/apps/frontend/.impeccable/review/regression-platform-desktop.png b/apps/frontend/.impeccable/review/regression-platform-desktop.png new file mode 100644 index 0000000..d670886 Binary files /dev/null and b/apps/frontend/.impeccable/review/regression-platform-desktop.png differ diff --git a/apps/frontend/.impeccable/review/tenant-dashboard-dark-desktop.png b/apps/frontend/.impeccable/review/tenant-dashboard-dark-desktop.png new file mode 100644 index 0000000..ecf3d62 Binary files /dev/null and b/apps/frontend/.impeccable/review/tenant-dashboard-dark-desktop.png differ diff --git a/apps/frontend/.impeccable/review/tenant-dashboard-desktop.png b/apps/frontend/.impeccable/review/tenant-dashboard-desktop.png new file mode 100644 index 0000000..235a239 Binary files /dev/null and b/apps/frontend/.impeccable/review/tenant-dashboard-desktop.png differ diff --git a/apps/frontend/.impeccable/review/tenant-dashboard-mobile.png b/apps/frontend/.impeccable/review/tenant-dashboard-mobile.png new file mode 100644 index 0000000..d9e17b0 Binary files /dev/null and b/apps/frontend/.impeccable/review/tenant-dashboard-mobile.png differ diff --git a/apps/frontend/.impeccable/review/tenant-drawer-open-mobile.png b/apps/frontend/.impeccable/review/tenant-drawer-open-mobile.png new file mode 100644 index 0000000..929ff4b Binary files /dev/null and b/apps/frontend/.impeccable/review/tenant-drawer-open-mobile.png differ diff --git a/apps/frontend/src/app/api/post-login/route.ts b/apps/frontend/src/app/api/post-login/route.ts new file mode 100644 index 0000000..100e094 --- /dev/null +++ b/apps/frontend/src/app/api/post-login/route.ts @@ -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("/auth/me", session.accessToken); + if (me.isPlatformUser) { + return NextResponse.json({ redirectTo: "/platform" }); + } + + const tenants = await apiFetch("/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; +} diff --git a/apps/frontend/src/app/app/layout.tsx b/apps/frontend/src/app/app/layout.tsx new file mode 100644 index 0000000..0e5aaac --- /dev/null +++ b/apps/frontend/src/app/app/layout.tsx @@ -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("/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 ( +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/apps/frontend/src/app/app/page.tsx b/apps/frontend/src/app/app/page.tsx new file mode 100644 index 0000000..a226d2f --- /dev/null +++ b/apps/frontend/src/app/app/page.tsx @@ -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("/reports/dashboard", session.accessToken); + + return ( +
+ {/* Estado ao vivo — chamadas e agentes primeiro (agente.md secao 162) */} +
+ + + 0} /> + 0} /> + + + +
+ + {/* Qualidade de atendimento hoje */} +
+ + + + +
+ + {/* Consumo do plano / valor estimado (agente.md secao 162) */} +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/app/login/page.tsx b/apps/frontend/src/app/login/page.tsx index a41c6f4..a93371f 100644 --- a/apps/frontend/src/app/login/page.tsx +++ b/apps/frontend/src/app/login/page.tsx @@ -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."); diff --git a/apps/frontend/src/app/platform/layout.tsx b/apps/frontend/src/app/platform/layout.tsx index 8f23582..5aebd1d 100644 --- a/apps/frontend/src/app/platform/layout.tsx +++ b/apps/frontend/src/app/platform/layout.tsx @@ -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("/auth/me", session.accessToken); + if (!me.isPlatformUser) redirect("/app"); return (
- +
{children}
diff --git a/apps/frontend/src/app/select-tenant/actions.ts b/apps/frontend/src/app/select-tenant/actions.ts new file mode 100644 index 0000000..69d90dc --- /dev/null +++ b/apps/frontend/src/app/select-tenant/actions.ts @@ -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"); +} diff --git a/apps/frontend/src/app/select-tenant/page.tsx b/apps/frontend/src/app/select-tenant/page.tsx new file mode 100644 index 0000000..79f22f9 --- /dev/null +++ b/apps/frontend/src/app/select-tenant/page.tsx @@ -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("/auth/tenants", session.accessToken); + + return ( +
+
+ B2BCall +

Qual conta você quer acessar?

+

Sua conta tem acesso a mais de um tenant.

+
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/app/select-tenant/picker.tsx b/apps/frontend/src/app/select-tenant/picker.tsx new file mode 100644 index 0000000..96f71fb --- /dev/null +++ b/apps/frontend/src/app/select-tenant/picker.tsx @@ -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(null); + const [pendingId, setPendingId] = useState(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 ( +
+ +
    + {tenants.map((tenant) => ( +
  • + +
  • + ))} +
+
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts index e08a4bc..3ec5ce3 100644 --- a/apps/frontend/src/components/platform-shell/nav-data.ts +++ b/apps/frontend/src/components/platform-shell/nav-data.ts @@ -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" }; -} diff --git a/apps/frontend/src/components/platform-shell/platform-sidebar.tsx b/apps/frontend/src/components/platform-shell/platform-sidebar.tsx new file mode 100644 index 0000000..2d0c227 --- /dev/null +++ b/apps/frontend/src/components/platform-shell/platform-sidebar.tsx @@ -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 ; +} diff --git a/apps/frontend/src/components/platform-shell/platform-topbar.tsx b/apps/frontend/src/components/platform-shell/platform-topbar.tsx new file mode 100644 index 0000000..0acba3c --- /dev/null +++ b/apps/frontend/src/components/platform-shell/platform-topbar.tsx @@ -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 ; +} diff --git a/apps/frontend/src/components/platform-shell/mobile-nav-drawer.tsx b/apps/frontend/src/components/shell/mobile-nav-drawer.tsx similarity index 90% rename from apps/frontend/src/components/platform-shell/mobile-nav-drawer.tsx rename to apps/frontend/src/components/shell/mobile-nav-drawer.tsx index a17a406..f7e1861 100644 --- a/apps/frontend/src/components/platform-shell/mobile-nav-drawer.tsx +++ b/apps/frontend/src/components/shell/mobile-nav-drawer.tsx @@ -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} > - Navegação da plataforma + Navegação
B2BCall @@ -45,7 +46,7 @@ export function MobileNavDrawer() {
diff --git a/apps/frontend/src/components/platform-shell/nav-list.tsx b/apps/frontend/src/components/shell/nav-list.tsx similarity index 71% rename from apps/frontend/src/components/platform-shell/nav-list.tsx rename to apps/frontend/src/components/shell/nav-list.tsx index cb5cc40..e4f4870 100644 --- a/apps/frontend/src/components/platform-shell/nav-list.tsx +++ b/apps/frontend/src/components/shell/nav-list.tsx @@ -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 (
    - {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 ( +
  • + + + {!collapsed && ( + <> + {section.label} + em breve + + )} + +
  • + ); + } + const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href)); return ( diff --git a/apps/frontend/src/components/shell/nav-types.ts b/apps/frontend/src/components/shell/nav-types.ts new file mode 100644 index 0000000..4a8b5e0 --- /dev/null +++ b/apps/frontend/src/components/shell/nav-types.ts @@ -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 }; +} diff --git a/apps/frontend/src/components/platform-shell/sidebar.tsx b/apps/frontend/src/components/shell/sidebar.tsx similarity index 86% rename from apps/frontend/src/components/platform-shell/sidebar.tsx rename to apps/frontend/src/components/shell/sidebar.tsx index c004124..ec6f466 100644 --- a/apps/frontend/src/components/platform-shell/sidebar.tsx +++ b/apps/frontend/src/components/shell/sidebar.tsx @@ -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() { )} >
    - {!collapsed && PLATFORM} + {!collapsed && {railLabel}}
    ); diff --git a/apps/frontend/src/components/platform-shell/topbar.tsx b/apps/frontend/src/components/shell/topbar.tsx similarity index 74% rename from apps/frontend/src/components/platform-shell/topbar.tsx rename to apps/frontend/src/components/shell/topbar.tsx index 8940154..cc05c27 100644 --- a/apps/frontend/src/components/platform-shell/topbar.tsx +++ b/apps/frontend/src/components/shell/topbar.tsx @@ -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 (
    - +

    {title}

    {description &&

    {description}

    } @@ -29,7 +29,7 @@ export function PlatformTopbar({ userEmail }: { userEmail: string }) {
    - {userEmail} + {userLabel}