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:
65
TODO.md
65
TODO.md
@@ -711,7 +711,70 @@ docs/BILLING.md
|
|||||||
`useState`/`useMemo` puro, sem cache de query; suficiente pro
|
`useState`/`useMemo` puro, sem cache de query; suficiente pro
|
||||||
tamanho de catálogo atual
|
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)
|
Security, Tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -55,7 +55,22 @@ export class AuthController {
|
|||||||
select: { id: true, email: true, name: true },
|
select: { id: true, email: true, name: true },
|
||||||
});
|
});
|
||||||
if (!dbUser) throw new NotFoundException();
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|||||||
@@ -30,6 +30,75 @@ function topCounts(lists: string[][], limit: number): { value: string; count: nu
|
|||||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
@Controller("reports")
|
@Controller("reports")
|
||||||
export class ReportsController {
|
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/
|
/** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/
|
||||||
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
|
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
|
||||||
* um limiar configurável via query (`slThresholdSeconds`, default 20s —
|
* um limiar configurável via query (`slThresholdSeconds`, default 20s —
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/regression-platform-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/regression-platform-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
apps/frontend/.impeccable/review/tenant-dashboard-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/tenant-dashboard-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/frontend/.impeccable/review/tenant-dashboard-mobile.png
Normal file
BIN
apps/frontend/.impeccable/review/tenant-dashboard-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/frontend/.impeccable/review/tenant-drawer-open-mobile.png
Normal file
BIN
apps/frontend/.impeccable/review/tenant-drawer-open-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
52
apps/frontend/src/app/api/post-login/route.ts
Normal file
52
apps/frontend/src/app/api/post-login/route.ts
Normal 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;
|
||||||
|
}
|
||||||
33
apps/frontend/src/app/app/layout.tsx
Normal file
33
apps/frontend/src/app/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/frontend/src/app/app/page.tsx
Normal file
79
apps/frontend/src/app/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,16 @@ export default function LoginPage() {
|
|||||||
setError(body.message ?? "Não foi possível entrar.");
|
setError(body.message ?? "Não foi possível entrar.");
|
||||||
return;
|
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();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Não foi possível entrar. Verifique sua conexão.");
|
setError("Não foi possível entrar. Verifique sua conexão.");
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { requireSession } from "@/lib/session";
|
import { requireSession } from "@/lib/session";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import { PlatformSidebar } from "@/components/platform-shell/sidebar";
|
import { PlatformSidebar } from "@/components/platform-shell/platform-sidebar";
|
||||||
import { PlatformTopbar } from "@/components/platform-shell/topbar";
|
import { PlatformTopbar } from "@/components/platform-shell/platform-topbar";
|
||||||
|
|
||||||
interface Me {
|
interface Me {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,12 +14,13 @@ interface Me {
|
|||||||
export default async function PlatformLayout({ children }: { children: React.ReactNode }) {
|
export default async function PlatformLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
||||||
|
if (!me.isPlatformUser) redirect("/app");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh overflow-hidden bg-background">
|
<div className="flex h-dvh overflow-hidden bg-background">
|
||||||
<PlatformSidebar />
|
<PlatformSidebar />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<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>
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
33
apps/frontend/src/app/select-tenant/actions.ts
Normal file
33
apps/frontend/src/app/select-tenant/actions.ts
Normal 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");
|
||||||
|
}
|
||||||
29
apps/frontend/src/app/select-tenant/page.tsx
Normal file
29
apps/frontend/src/app/select-tenant/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/frontend/src/app/select-tenant/picker.tsx
Normal file
65
apps/frontend/src/app/select-tenant/picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,5 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import { LayoutDashboard, Building2, CreditCard, ServerCog, Sparkles, Settings2 } from "lucide-react";
|
||||||
import {
|
import type { NavSection } from "../shell/nav-types";
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** IA fixa do menu Platform (agente.md secao 168) — "Visão Geral" e
|
/** 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
|
* "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" }],
|
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 * as Dialog from "@radix-ui/react-dialog";
|
||||||
import { Menu, X } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
import { NavList } from "./nav-list";
|
import { NavList } from "./nav-list";
|
||||||
|
import type { NavSection } from "./nav-types";
|
||||||
|
|
||||||
/** Drawer de navegação pra telas abaixo de `lg` (secao 175: notebook/
|
/** Drawer de navegação pra telas abaixo de `lg` (secao 175: notebook/
|
||||||
* tablet são prioridade, mas o próprio celular do agente precisa
|
* 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.
|
* 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:
|
* Radix Dialog dá foco preso + Escape + clique fora de graça (secao 176:
|
||||||
* navegação completa por teclado). */
|
* navegação completa por teclado). */
|
||||||
export function MobileNavDrawer() {
|
export function MobileNavDrawer({ items }: { items: NavSection[] }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
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"
|
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}
|
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">
|
<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} />
|
<Image src="/branding/b2blogo.png" alt="B2BCall" width={110} height={27} />
|
||||||
<Dialog.Close asChild>
|
<Dialog.Close asChild>
|
||||||
@@ -45,7 +46,7 @@ export function MobileNavDrawer() {
|
|||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
|
<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>
|
</nav>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
@@ -3,17 +3,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
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
|
/** Lista de navegação compartilhada entre a sidebar fixa (desktop, secao
|
||||||
* 166) e o drawer mobile (`MobileNavDrawer`) — uma única fonte de verdade
|
* 166) e o drawer mobile (`MobileNavDrawer`) — a mesma implementação
|
||||||
* pra estado ativo e pro "em breve" dos itens ainda não construídos. */
|
* serve o menu Platform e o menu Tenant (secao 168-169), só os dados
|
||||||
export function NavList({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
|
* (`items`) mudam. */
|
||||||
|
export function NavList({ items, collapsed = false, onNavigate }: { items: NavSection[]; collapsed?: boolean; onNavigate?: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{PLATFORM_NAV.map((section) => {
|
{items.map((section) => {
|
||||||
const Icon = section.icon;
|
const Icon = section.icon;
|
||||||
const active = section.href && pathname === section.href;
|
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));
|
const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href));
|
||||||
|
|
||||||
return (
|
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 { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { NavList } from "./nav-list";
|
import { NavList } from "./nav-list";
|
||||||
|
import type { NavSection } from "./nav-types";
|
||||||
|
|
||||||
/** Sidebar fixa (secao 166) — só em telas lg+; abaixo disso vira drawer
|
/** Sidebar fixa (secao 166) — só em telas lg+; abaixo disso vira drawer
|
||||||
* (`MobileNavDrawer`, acionado pela topbar) porque uma coluna de 256px
|
* (`MobileNavDrawer`, acionado pela topbar) porque uma coluna de 256px
|
||||||
* fixa não cabe num viewport de celular (bug real achado construindo a
|
* fixa não cabe num viewport de celular (bug real achado construindo a
|
||||||
* tela de Tarifas: o conteúdo ficava espremido em ~130px). */
|
* 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);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +21,7 @@ export function PlatformSidebar() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCollapsed((c) => !c)}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
@@ -33,7 +34,7 @@ export function PlatformSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Navegação principal">
|
<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>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "../platform-shell/theme-toggle";
|
||||||
import { MobileNavDrawer } from "./mobile-nav-drawer";
|
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 router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { title, description } = getPageMeta(pathname);
|
const { title, description } = getPageMeta(items, pathname, fallbackTitle);
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
await fetch("/api/logout", { method: "POST" });
|
await fetch("/api/logout", { method: "POST" });
|
||||||
@@ -20,7 +20,7 @@ export function PlatformTopbar({ userEmail }: { userEmail: string }) {
|
|||||||
return (
|
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">
|
<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">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<MobileNavDrawer />
|
<MobileNavDrawer items={items} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{title}</p>
|
<p className="truncate text-sm font-semibold text-foreground">{title}</p>
|
||||||
{description && <p className="truncate text-xs text-muted-foreground">{description}</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">
|
<div className="flex shrink-0 items-center gap-2 sm:gap-4">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<div className="hidden h-6 w-px bg-border sm:block" aria-hidden />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onLogout}
|
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} />;
|
||||||
|
}
|
||||||
@@ -2,6 +2,18 @@ export function formatInt(n: number): string {
|
|||||||
return new Intl.NumberFormat("pt-BR").format(Math.round(n));
|
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 } {
|
export function formatBytes(bytes: number): { value: string; unit: string } {
|
||||||
if (bytes === 0) return { value: "0", unit: "B" };
|
if (bytes === 0) return { value: "0", unit: "B" };
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
|||||||
Reference in New Issue
Block a user