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

@@ -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 —