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

@@ -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>
);
}