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:
@@ -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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user