feat(platform): Clientes > Tenants e Planos — CRUD real (backend novo)
Não existia NENHUM endpoint pra criar/listar/editar tenant nem plano até aqui — só via script/seed ad hoc. Dois controllers novos: TenantsController (/tenants) e PlansController (/plans), platform-only. POST /tenants cria o tenant E o primeiro usuário (Tenant Admin) numa transação só — sem esse usuário o tenant fica inacessível. Senha gerada e devolvida em texto puro só na resposta de criação (revela uma vez, mesmo padrão de Ramais/SIP). Bug real achado testando o próprio endpoint: GET /tenants calculava memberCount sem contexto de RLS — tenant_memberships tem FORCE RLS, então nem platform admin enxerga linha nenhuma sem app.current_tenant_id setado, o campo sempre voltava 0. Corrigido abrindo o contexto de cada tenant um de cada vez. Frontend: /platform/clientes/tenants (lista+busca), /tenants/new (cria tenant+admin, revela senha), /tenants/:id (troca status/plano, preview dos limites ao vivo), /platform/clientes/planos (CRUD completo dos limites). Testado ponta a ponta: plano novo -> tenant novo com admin real -> troca de plano persistida, confirmada via API. Smoke test nas 19 telas anteriores, todas 200. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
55
TODO.md
@@ -1176,6 +1176,61 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
|
|||||||
reconexão do socket.io real antes de fechar o stream, mas não há
|
reconexão do socket.io real antes de fechar o stream, mas não há
|
||||||
backoff exponencial nem um teste de queda de rede prolongada
|
backoff exponencial nem um teste de queda de rede prolongada
|
||||||
|
|
||||||
|
## PHASE 33 — Platform: Clientes > Tenants e Planos (CRUD real, backend
|
||||||
|
novo) (agente.md secao 29, 56, 126, 141, 168)
|
||||||
|
- [x] **Lacuna de backend fechada primeiro**: não existia NENHUM endpoint
|
||||||
|
pra criar/listar/editar tenant nem plano até aqui — só via script/
|
||||||
|
seed ad hoc (a própria Acme foi criada assim numa sessão anterior,
|
||||||
|
sem deixar rastro reproduzível). Dois controllers novos:
|
||||||
|
`TenantsController` (`/tenants`, `tenants.manage`/`.view`) e
|
||||||
|
`PlansController` (`/plans`, `pricing.manage`), ambos platform-only
|
||||||
|
(`isPlatformUser`, mesmo padrão de billing).
|
||||||
|
- [x] `POST /tenants` cria o tenant **e** o primeiro usuário (Tenant
|
||||||
|
Admin) numa transação só (secao 141: sem esse usuário o tenant fica
|
||||||
|
inacessível) — `Tenant.create` + `User.create` + `TenantMembership`
|
||||||
|
+ `UserRole(tenant_admin)`, senha gerada
|
||||||
|
(`generateStrongPassword`) e devolvida em texto puro só nesta
|
||||||
|
resposta (mesmo padrão de "revela uma vez" de Ramais/SIP).
|
||||||
|
`telephonyDomain` fixo em `b2bcall.local` (decisão já tomada na
|
||||||
|
PHASE 08 — sem multi-domínio real ainda).
|
||||||
|
- [x] **Bug real, achado testando o próprio endpoint antes de expor no
|
||||||
|
frontend**: `GET /tenants` calculava `memberCount` com
|
||||||
|
`tenantMembership.groupBy` numa query sem contexto de tenant
|
||||||
|
nenhum — `tenant_memberships` tem FORCE RLS (secao 32), então
|
||||||
|
**nem platform admin** enxerga uma linha sequer sem
|
||||||
|
`app.current_tenant_id` setado; o campo sempre voltava `0`.
|
||||||
|
Corrigido abrindo o contexto de RLS de cada tenant um de cada vez
|
||||||
|
(`Promise.all` de `withTenantContext` por tenant) — aceitável numa
|
||||||
|
tela de administração, não é hot path.
|
||||||
|
- [x] Frontend: `/platform/clientes/tenants` (lista com busca, badge de
|
||||||
|
status próprio — não reusa `StatusBadge`, mesma cautela de gênero
|
||||||
|
já aplicada em `AgentStateBadge`: "Ativo" de tenant não é "Ativa"
|
||||||
|
de campanha), `/tenants/new` (cria tenant+admin, revela senha
|
||||||
|
temporária uma vez), `/tenants/:id` (troca status/plano, mostra os
|
||||||
|
limites do plano selecionado ao vivo antes de salvar).
|
||||||
|
`/platform/clientes/planos` (cards com todos os limites, criar e
|
||||||
|
editar inline — campo de limite em branco = sem limite, nunca
|
||||||
|
zero).
|
||||||
|
- [x] Testado ponta a ponta contra a API real: criado plano "Profissional"
|
||||||
|
(50 ramais/agentes, resto sem limite) → criado tenant "Beta Corp
|
||||||
|
Telecom LTDA" com admin `admin@beta.b2bcall.local`, senha revelada
|
||||||
|
uma vez → detalhe do tenant → trocado plano pra "Profissional" →
|
||||||
|
salvo → confirmado via API que persistiu (`GET /tenants/:id`
|
||||||
|
retornando o plano novo). Smoke test de regressão nas 19 telas do
|
||||||
|
tenant + tarifas, todas 200.
|
||||||
|
- [ ] "Clientes > Assinaturas" e "> Quotas" continuam "em breve" —
|
||||||
|
`SubscriptionsController`/`PlanVersionsController` já existem
|
||||||
|
(PHASE 22) mas sem tela; Quotas ficou parcialmente coberta pelos
|
||||||
|
limites do plano já visíveis no detalhe do tenant, sem uma tela
|
||||||
|
dedicada de consumo-vs-limite ainda
|
||||||
|
(esperando **Billing > Consumo**, que depende do mesmo seletor de
|
||||||
|
tenant)
|
||||||
|
- [ ] Sem exclusão/soft-delete de tenant nem de plano pela UI (só
|
||||||
|
suspender via status) — decisão deliberada, mesma cautela de
|
||||||
|
qualquer ação destrutiva em dado de cliente real
|
||||||
|
- [ ] `Plan.key` não pode ser editado depois de criado (só os limites) —
|
||||||
|
não implementado, `key` normalmente não deveria mudar mesmo
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Riscos conhecidos
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { AIModule } from "./ai/ai.module";
|
|||||||
import { QualityModule } from "./quality/quality.module";
|
import { QualityModule } from "./quality/quality.module";
|
||||||
import { PlatformModule } from "./platform/platform.module";
|
import { PlatformModule } from "./platform/platform.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
|
import { TenantsModule } from "./tenants/tenants.module";
|
||||||
|
import { PlansModule } from "./plans/plans.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,6 +46,8 @@ import { BillingModule } from "./billing/billing.module";
|
|||||||
QualityModule,
|
QualityModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
TenantsModule,
|
||||||
|
PlansModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
169
apps/api/src/plans/dto/create-plan.dto.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, Matches, MaxLength, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class CreatePlanDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
@Matches(/^[a-z0-9_-]+$/, { message: "key deve ser minusculo, so letras/numeros/hifen/underscore" })
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxExtensions?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxAgents?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxTrunks?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxQueues?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCampaigns?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCps?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxConcurrentCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxDailyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxMonthlyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxRecordingStorageGb?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
recordingRetentionDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
transcriptionRetentionDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
recordingEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiTranscriptionEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiAnalysisEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
apiAccessEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePlanDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxExtensions?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxAgents?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxTrunks?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxQueues?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCampaigns?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxCps?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxConcurrentCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxDailyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxMonthlyCalls?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxRecordingStorageGb?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
recordingEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiTranscriptionEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
aiAnalysisEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
apiAccessEnabled?: boolean;
|
||||||
|
}
|
||||||
87
apps/api/src/plans/plans.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { CreatePlanDto, UpdatePlanDto } from "./dto/create-plan.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catálogo de planos (agente.md secao 56, 126) — global, sem RLS (mesmo
|
||||||
|
* critério de `plans` desde a PHASE 14: "sem tenant_id, catálogo
|
||||||
|
* compartilhado"). Só platform admin gerencia; qualquer campo de limite
|
||||||
|
* `null` significa "sem limite" (nunca "sem plano" — `Tenant.planId` é
|
||||||
|
* obrigatório desde a migration que fez o backfill).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("plans")
|
||||||
|
export class PlansController {
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePlanDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode criar planos");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const existing = await prisma.plan.findUnique({ where: { key: dto.key } });
|
||||||
|
if (existing) {
|
||||||
|
throw new ForbiddenException(`Ja existe um plano com key "${dto.key}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await prisma.plan.create({ data: dto });
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PLAN_CREATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "plan",
|
||||||
|
entityId: plan.id,
|
||||||
|
after: { key: plan.key, name: plan.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return prisma.plan.findMany({ orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const plan = await prisma.plan.findUnique({ where: { id } });
|
||||||
|
if (!plan) throw new NotFoundException();
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("pricing.manage")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdatePlanDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode editar planos");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const existing = await prisma.plan.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
const plan = await prisma.plan.update({ where: { id }, data: dto });
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PLAN_UPDATE",
|
||||||
|
tenantId: null,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "plan",
|
||||||
|
entityId: plan.id,
|
||||||
|
after: { ...dto },
|
||||||
|
});
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/plans/plans.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PlansController } from "./plans.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlansController],
|
||||||
|
})
|
||||||
|
export class PlansModule {}
|
||||||
53
apps/api/src/tenants/dto/create-tenant.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { IsEmail, IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
const TENANT_STATUSES = ["TRIAL", "ACTIVE", "SUSPENDED", "PAST_DUE", "CANCELLED"];
|
||||||
|
|
||||||
|
export class CreateTenantDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
@Matches(/^[a-z0-9-]+$/, { message: "code deve ser minusculo, so letras/numeros/hifen (vira tambem o slug)" })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
legalName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
tradeName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
taxId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
planId!: string;
|
||||||
|
|
||||||
|
/** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa
|
||||||
|
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
|
||||||
|
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo
|
||||||
|
* padrão de `SecretReveal` já usado pra senha SIP de ramal. */
|
||||||
|
@IsEmail()
|
||||||
|
adminEmail!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
adminName!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateTenantDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TENANT_STATUSES)
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
planId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
tradeName?: string;
|
||||||
|
}
|
||||||
163
apps/api/src/tenants/tenants.controller.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext, type TenantStatus } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, isPlatformUser, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { generateStrongPassword } from "@b2bcall/shared";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { CreateTenantDto, UpdateTenantDto } from "./dto/create-tenant.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD de tenants (agente.md secao 29, 141, 168 "Clientes > Tenants") —
|
||||||
|
* a peça que faltava desde a PHASE 01: até aqui só existia via script/API
|
||||||
|
* direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md),
|
||||||
|
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
|
||||||
|
*
|
||||||
|
* `telephonyDomain` fixo em "b2bcall.local" pra todo tenant novo — decisão
|
||||||
|
* já tomada na PHASE 08 (multi-domínio real por tenant não existe ainda).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("tenants")
|
||||||
|
export class TenantsController {
|
||||||
|
@RequirePermission("tenants.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateTenantDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode criar tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const [existingCode, existingEmail, plan] = await Promise.all([
|
||||||
|
prisma.tenant.findFirst({ where: { code: dto.code } }),
|
||||||
|
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
|
||||||
|
prisma.plan.findUnique({ where: { id: dto.planId } }),
|
||||||
|
]);
|
||||||
|
if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`);
|
||||||
|
if (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`);
|
||||||
|
if (!plan) throw new ForbiddenException("Plano nao encontrado");
|
||||||
|
|
||||||
|
const temporaryPassword = generateStrongPassword();
|
||||||
|
const passwordHash = await hashPassword(temporaryPassword);
|
||||||
|
|
||||||
|
const { tenant, admin } = await prisma.$transaction(async (tx) => {
|
||||||
|
const tenant = await tx.tenant.create({
|
||||||
|
data: {
|
||||||
|
code: dto.code,
|
||||||
|
slug: dto.code,
|
||||||
|
legalName: dto.legalName,
|
||||||
|
tradeName: dto.tradeName,
|
||||||
|
taxId: dto.taxId,
|
||||||
|
planId: dto.planId,
|
||||||
|
telephonyDomain: "b2bcall.local",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await tx.user.create({
|
||||||
|
data: { email: dto.adminEmail, passwordHash, name: dto.adminName, mustChangePassword: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// RLS de tenant_memberships (secao 32) exige app.current_tenant_id —
|
||||||
|
// já dentro da mesma transação, sem precisar de withTenantContext
|
||||||
|
// (que abriria uma transação aninhada em cima de um PrismaClient
|
||||||
|
// plano, não de um TransactionClient).
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenant.id}, true)`;
|
||||||
|
await tx.tenantMembership.create({ data: { tenantId: tenant.id, userId: admin.id } });
|
||||||
|
|
||||||
|
const tenantAdminRole = await tx.role.findUniqueOrThrow({ where: { key: "tenant_admin" } });
|
||||||
|
await tx.userRole.create({ data: { userId: admin.id, roleId: tenantAdminRole.id, tenantId: tenant.id } });
|
||||||
|
|
||||||
|
return { tenant, admin };
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TENANT_CREATE",
|
||||||
|
tenantId: tenant.id,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tenant",
|
||||||
|
entityId: tenant.id,
|
||||||
|
after: { code: tenant.code, legalName: tenant.legalName, planId: tenant.planId, adminEmail: admin.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tenant, admin: { email: admin.email, temporaryPassword } };
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode listar tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: { plan: { select: { id: true, key: true, name: true } } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// `tenant_memberships` tem FORCE RLS — nem platform admin enxerga
|
||||||
|
// linha nenhuma numa query sem `app.current_tenant_id` setado (deny-
|
||||||
|
// by-default, secao 32). Sem uma policy própria pra platform admin
|
||||||
|
// "ver tudo", a única forma correta é abrir o contexto de cada tenant
|
||||||
|
// um de cada vez — aceitável aqui, é uma tela de administração, não
|
||||||
|
// um hot path.
|
||||||
|
const memberCounts = await Promise.all(
|
||||||
|
tenants.map((t) => withTenantContext(prisma, t.id, (tx) => tx.tenantMembership.count({ where: { tenantId: t.id } }))),
|
||||||
|
);
|
||||||
|
const memberCount = new Map(tenants.map((t, i) => [t.id, memberCounts[i]]));
|
||||||
|
return tenants.map((t) => ({ ...t, memberCount: memberCount.get(t.id) ?? 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("tenants.view")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode ver detalhe de tenant");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenant = await prisma.tenant.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
if (!tenant) throw new NotFoundException();
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("tenants.manage")
|
||||||
|
@Patch(":id")
|
||||||
|
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateTenantDto) {
|
||||||
|
if (!(await isPlatformUser(user.sub))) {
|
||||||
|
throw new ForbiddenException("So' um usuario com role de plataforma pode editar tenants");
|
||||||
|
}
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const existing = await prisma.tenant.findFirst({ where: { id, deletedAt: null } });
|
||||||
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
if (dto.planId) {
|
||||||
|
const plan = await prisma.plan.findUnique({ where: { id: dto.planId } });
|
||||||
|
if (!plan) throw new ForbiddenException("Plano nao encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.status ? { status: dto.status as TenantStatus } : {}),
|
||||||
|
...(dto.planId ? { planId: dto.planId } : {}),
|
||||||
|
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
|
||||||
|
},
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TENANT_UPDATE",
|
||||||
|
tenantId: tenant.id,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tenant",
|
||||||
|
entityId: tenant.id,
|
||||||
|
after: { ...dto },
|
||||||
|
});
|
||||||
|
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/tenants/tenants.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TenantsController } from "./tenants.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TenantsController],
|
||||||
|
})
|
||||||
|
export class TenantsModule {}
|
||||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 84 KiB |
70
apps/frontend/src/app/platform/clientes/planos/actions.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import type { Plan } from "@/lib/platform-types";
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message);
|
||||||
|
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||||
|
if (typeof parsed.message === "string") return parsed.message;
|
||||||
|
} catch {
|
||||||
|
// corpo não era JSON
|
||||||
|
}
|
||||||
|
return err.message || "Falha inesperada na API.";
|
||||||
|
}
|
||||||
|
return "Falha inesperada. Tente novamente.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanLimitsInput = Partial<
|
||||||
|
Pick<
|
||||||
|
Plan,
|
||||||
|
| "maxExtensions"
|
||||||
|
| "maxAgents"
|
||||||
|
| "maxTrunks"
|
||||||
|
| "maxQueues"
|
||||||
|
| "maxCampaigns"
|
||||||
|
| "maxCps"
|
||||||
|
| "maxConcurrentCalls"
|
||||||
|
| "maxDailyCalls"
|
||||||
|
| "maxMonthlyCalls"
|
||||||
|
| "maxRecordingStorageGb"
|
||||||
|
| "recordingEnabled"
|
||||||
|
| "aiEnabled"
|
||||||
|
| "aiTranscriptionEnabled"
|
||||||
|
| "aiAnalysisEnabled"
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface CreatePlanInput extends PlanLimitsInput {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPlan(input: CreatePlanInput): Promise<{ ok: true; plan: Plan } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const plan = await apiFetch<Plan>("/plans", session.accessToken, { method: "POST", body: JSON.stringify(input) });
|
||||||
|
revalidatePath("/platform/clientes/planos");
|
||||||
|
return { ok: true, plan };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlan(
|
||||||
|
id: string,
|
||||||
|
input: PlanLimitsInput & { name?: string },
|
||||||
|
): Promise<{ ok: true; plan: Plan } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const plan = await apiFetch<Plan>(`/plans/${id}`, session.accessToken, { method: "PATCH", body: JSON.stringify(input) });
|
||||||
|
revalidatePath("/platform/clientes/planos");
|
||||||
|
return { ok: true, plan };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/platform/clientes/planos/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { Plan } from "@/lib/platform-types";
|
||||||
|
import { PlanosView } from "./planos-view";
|
||||||
|
|
||||||
|
export default async function PlanosPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const plans = await apiFetch<Plan[]>("/plans", session.accessToken);
|
||||||
|
return <PlanosView plans={plans} />;
|
||||||
|
}
|
||||||
260
apps/frontend/src/app/platform/clientes/planos/planos-view.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CreditCard, Plus, X } from "lucide-react";
|
||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input, FieldLabel } from "@/components/ui/input";
|
||||||
|
import { Pill } from "@/components/ui/pill";
|
||||||
|
import { EmptyState } from "@/components/ui/table";
|
||||||
|
import { PLAN_LIMIT_FIELDS, type Plan } from "@/lib/platform-types";
|
||||||
|
import { createPlan, updatePlan, type PlanLimitsInput } from "./actions";
|
||||||
|
|
||||||
|
type LimitsState = Record<string, string>;
|
||||||
|
|
||||||
|
function limitsToState(plan?: Plan): LimitsState {
|
||||||
|
const state: LimitsState = {};
|
||||||
|
for (const { key } of PLAN_LIMIT_FIELDS) {
|
||||||
|
state[key] = plan?.[key] == null ? "" : String(plan[key]);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitsToInput(state: LimitsState): PlanLimitsInput {
|
||||||
|
const input: PlanLimitsInput = {};
|
||||||
|
for (const { key } of PLAN_LIMIT_FIELDS) {
|
||||||
|
const raw = state[key];
|
||||||
|
(input as Record<string, number | undefined>)[key] = raw.trim() === "" ? undefined : Number(raw);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanosView({ plans }: { plans: Plan[] }) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Planos</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Catálogo de planos (agente.md secao 56, 126) — campo de limite em branco significa{" "}
|
||||||
|
<strong>sem limite</strong>, nunca zero.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||||
|
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||||
|
{showForm ? "Cancelar" : "Novo plano"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && <NewPlanForm onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
|
{plans.length === 0 ? (
|
||||||
|
<Panel>
|
||||||
|
<EmptyState title="Nenhum plano cadastrado ainda" description="Crie o primeiro plano da plataforma." />
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<PlanCard key={plan.id} plan={plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitFields({ state, onChange, disabled }: { state: LimitsState; onChange: (key: string, value: string) => void; disabled: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
|
{PLAN_LIMIT_FIELDS.map(({ key, label }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<FieldLabel htmlFor={`pl-${key}`}>{label}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`pl-${key}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={state[key]}
|
||||||
|
onChange={(e) => onChange(key, e.target.value)}
|
||||||
|
placeholder="sem limite"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlagToggle({ label, checked, onChange, disabled }: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled: boolean }) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewPlanForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [limits, setLimits] = useState<LimitsState>(() => limitsToState());
|
||||||
|
const [recordingEnabled, setRecordingEnabled] = useState(true);
|
||||||
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
|
const [aiTranscriptionEnabled, setAiTranscriptionEnabled] = useState(false);
|
||||||
|
const [aiAnalysisEnabled, setAiAnalysisEnabled] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(key) || !name.trim()) {
|
||||||
|
setError("Key (minúsculo) e nome são obrigatórios.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createPlan({
|
||||||
|
key,
|
||||||
|
name: name.trim(),
|
||||||
|
...limitsToInput(limits),
|
||||||
|
recordingEnabled,
|
||||||
|
aiEnabled,
|
||||||
|
aiTranscriptionEnabled,
|
||||||
|
aiAnalysisEnabled,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pl-key">Key</FieldLabel>
|
||||||
|
<Input id="pl-key" value={key} onChange={(e) => setKey(e.target.value.toLowerCase())} placeholder="ex.: pro" className="font-mono" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="pl-name">Nome</FieldLabel>
|
||||||
|
<Input id="pl-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Profissional" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LimitFields state={limits} onChange={(k, v) => setLimits((prev) => ({ ...prev, [k]: v }))} disabled={pending} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<FlagToggle label="Gravação habilitada" checked={recordingEnabled} onChange={setRecordingEnabled} disabled={pending} />
|
||||||
|
<FlagToggle label="IA habilitada" checked={aiEnabled} onChange={setAiEnabled} disabled={pending} />
|
||||||
|
<FlagToggle label="IA — transcrição" checked={aiTranscriptionEnabled} onChange={setAiTranscriptionEnabled} disabled={pending} />
|
||||||
|
<FlagToggle label="IA — análise" checked={aiAnalysisEnabled} onChange={setAiAnalysisEnabled} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Criando…" : "Criar plano"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan }: { plan: Plan }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [limits, setLimits] = useState<LimitsState>(() => limitsToState(plan));
|
||||||
|
const [recordingEnabled, setRecordingEnabled] = useState(plan.recordingEnabled);
|
||||||
|
const [aiEnabled, setAiEnabled] = useState(plan.aiEnabled);
|
||||||
|
const [aiTranscriptionEnabled, setAiTranscriptionEnabled] = useState(plan.aiTranscriptionEnabled);
|
||||||
|
const [aiAnalysisEnabled, setAiAnalysisEnabled] = useState(plan.aiAnalysisEnabled);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updatePlan(plan.id, {
|
||||||
|
...limitsToInput(limits),
|
||||||
|
recordingEnabled,
|
||||||
|
aiEnabled,
|
||||||
|
aiTranscriptionEnabled,
|
||||||
|
aiAnalysisEnabled,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{plan.name}</h2>
|
||||||
|
<Pill>{plan.key}</Pill>
|
||||||
|
</div>
|
||||||
|
{!editing && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<LimitFields state={limits} onChange={(k, v) => setLimits((prev) => ({ ...prev, [k]: v }))} disabled={pending} />
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<FlagToggle label="Gravação habilitada" checked={recordingEnabled} onChange={setRecordingEnabled} disabled={pending} />
|
||||||
|
<FlagToggle label="IA habilitada" checked={aiEnabled} onChange={setAiEnabled} disabled={pending} />
|
||||||
|
<FlagToggle label="IA — transcrição" checked={aiTranscriptionEnabled} onChange={setAiTranscriptionEnabled} disabled={pending} />
|
||||||
|
<FlagToggle label="IA — análise" checked={aiAnalysisEnabled} onChange={setAiAnalysisEnabled} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
{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 className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={pending}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
|
||||||
|
{pending ? "Salvando…" : "Salvar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-5">
|
||||||
|
{PLAN_LIMIT_FIELDS.map(({ key, label }) => (
|
||||||
|
<li key={key} className="flex flex-col">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-mono tabular-nums text-foreground">{plan[key] == null ? "sem limite" : String(plan[key])}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import type { Plan, Tenant } from "@/lib/platform-types";
|
||||||
|
import { TenantDetailView } from "./tenant-detail-view";
|
||||||
|
|
||||||
|
export default async function TenantDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await requireSession();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [tenant, plans] = await Promise.all([
|
||||||
|
apiFetch<Tenant & { plan: Plan }>(`/tenants/${id}`, session.accessToken),
|
||||||
|
apiFetch<Plan[]>("/plans", session.accessToken),
|
||||||
|
]);
|
||||||
|
return <TenantDetailView tenant={tenant} plans={plans} />;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 404) notFound();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Building2 } from "lucide-react";
|
||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, FieldLabel } from "@/components/ui/input";
|
||||||
|
import { Pill } from "@/components/ui/pill";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { PLAN_LIMIT_FIELDS, TENANT_STATUS_LABELS, type Plan, type Tenant } from "@/lib/platform-types";
|
||||||
|
import { updateTenant } from "../actions";
|
||||||
|
|
||||||
|
const STATUSES = ["TRIAL", "ACTIVE", "SUSPENDED", "PAST_DUE", "CANCELLED"];
|
||||||
|
|
||||||
|
export function TenantDetailView({ tenant, plans }: { tenant: Tenant & { plan: Plan }; plans: Plan[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [status, setStatus] = useState(tenant.status);
|
||||||
|
const [planId, setPlanId] = useState(tenant.planId);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const dirty = status !== tenant.status || planId !== tenant.planId;
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateTenant(tenant.id, { status, planId });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPlan = plans.find((p) => p.id === planId) ?? tenant.plan;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground" aria-hidden />
|
||||||
|
{tenant.legalName}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Código <span className="font-mono">{tenant.code}</span> — criado em {formatDate(tenant.createdAt)},{" "}
|
||||||
|
{tenant.memberCount} usuário(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<p className="text-sm font-medium text-foreground">Status e plano</p>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="td-status">Status</FieldLabel>
|
||||||
|
<Select id="td-status" value={status} onChange={(e) => setStatus(e.target.value as Tenant["status"])} disabled={pending}>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{TENANT_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="td-plan">Plano</FieldLabel>
|
||||||
|
<Select id="td-plan" value={planId} onChange={(e) => setPlanId(e.target.value)} disabled={pending}>
|
||||||
|
{plans.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && !dirty && <p className="text-sm text-status-green">Salvo.</p>}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" size="sm" onClick={onSave} disabled={pending || !dirty}>
|
||||||
|
{pending ? "Salvando…" : "Salvar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title={`Limites do plano ${selectedPlan.name}`} description="null = sem limite (agente.md secao 56)" />
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{PLAN_LIMIT_FIELDS.map(({ key, label }) => (
|
||||||
|
<li key={key} className="flex items-center justify-between px-5 py-2.5 text-sm">
|
||||||
|
<span className="text-foreground">{label}</span>
|
||||||
|
<span className="font-mono tabular-nums text-muted-foreground">
|
||||||
|
{selectedPlan[key] == null ? "sem limite" : String(selectedPlan[key])}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="flex items-center justify-between px-5 py-2.5 text-sm">
|
||||||
|
<span className="text-foreground">Gravação habilitada</span>
|
||||||
|
<Pill tone={selectedPlan.recordingEnabled ? "accent" : "neutral"}>{selectedPlan.recordingEnabled ? "Sim" : "Não"}</Pill>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center justify-between px-5 py-2.5 text-sm">
|
||||||
|
<span className="text-foreground">IA habilitada</span>
|
||||||
|
<Pill tone={selectedPlan.aiEnabled ? "accent" : "neutral"}>{selectedPlan.aiEnabled ? "Sim" : "Não"}</Pill>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/frontend/src/app/platform/clientes/tenants/actions.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import type { Tenant } from "@/lib/platform-types";
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.message);
|
||||||
|
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||||
|
if (typeof parsed.message === "string") return parsed.message;
|
||||||
|
} catch {
|
||||||
|
// corpo não era JSON
|
||||||
|
}
|
||||||
|
return err.message || "Falha inesperada na API.";
|
||||||
|
}
|
||||||
|
return "Falha inesperada. Tente novamente.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantInput {
|
||||||
|
code: string;
|
||||||
|
legalName: string;
|
||||||
|
tradeName?: string;
|
||||||
|
taxId?: string;
|
||||||
|
planId: string;
|
||||||
|
adminEmail: string;
|
||||||
|
adminName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantResult {
|
||||||
|
tenant: Tenant;
|
||||||
|
admin: { email: string; temporaryPassword: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(input: CreateTenantInput): Promise<({ ok: true } & CreateTenantResult) | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<CreateTenantResult>("/tenants", session.accessToken, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
revalidatePath("/platform/clientes/tenants");
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTenantInput {
|
||||||
|
status?: string;
|
||||||
|
planId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(id: string, input: UpdateTenantInput): Promise<{ ok: true; tenant: Tenant } | { ok: false; error: string }> {
|
||||||
|
const session = await requireSession();
|
||||||
|
try {
|
||||||
|
const tenant = await apiFetch<Tenant>(`/tenants/${id}`, session.accessToken, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
revalidatePath("/platform/clientes/tenants");
|
||||||
|
revalidatePath(`/platform/clientes/tenants/${id}`);
|
||||||
|
return { ok: true, tenant };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractErrorMessage(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
165
apps/frontend/src/app/platform/clientes/tenants/new/form.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Panel } from "@/components/ui/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||||
|
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||||
|
import type { Plan } from "@/lib/platform-types";
|
||||||
|
import { createTenant, type CreateTenantResult } from "../actions";
|
||||||
|
|
||||||
|
export function NewTenantForm({ plans }: { plans: Plan[] }) {
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [legalName, setLegalName] = useState("");
|
||||||
|
const [tradeName, setTradeName] = useState("");
|
||||||
|
const [taxId, setTaxId] = useState("");
|
||||||
|
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
|
||||||
|
const [adminEmail, setAdminEmail] = useState("");
|
||||||
|
const [adminName, setAdminName] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [created, setCreated] = useState<CreateTenantResult | null>(null);
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!/^[a-z0-9-]+$/.test(code)) {
|
||||||
|
setError("Código deve ser minúsculo, só letras/números/hífen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!legalName.trim() || !adminEmail.trim() || !adminName.trim() || !planId) {
|
||||||
|
setError("Razão social, plano, e-mail e nome do admin são obrigatórios.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createTenant({
|
||||||
|
code,
|
||||||
|
legalName: legalName.trim(),
|
||||||
|
tradeName: tradeName.trim() || undefined,
|
||||||
|
taxId: taxId.trim() || undefined,
|
||||||
|
planId,
|
||||||
|
adminEmail: adminEmail.trim(),
|
||||||
|
adminName: adminName.trim(),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreated(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Panel className="flex items-center gap-3 p-5">
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-status-green/15 text-status-green">
|
||||||
|
<Check className="h-5 w-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">Tenant {created.tenant.legalName} criado</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Admin: <span className="font-mono">{created.admin.email}</span> (troca de senha obrigatória no
|
||||||
|
primeiro login)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<SecretReveal label="Senha temporária do admin" value={created.admin.temporaryPassword} />
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/platform/clientes/tenants">Voltar pra lista</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/platform/clientes/tenants/${created.tenant.id}`}>Ver tenant</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plans.length === 0) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nenhum plano cadastrado ainda. <Link href="/platform/clientes/planos" className="text-primary underline-offset-4 hover:underline">Crie um plano</Link> antes de criar um tenant.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="t-code">Código (identificador único)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="t-code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toLowerCase())}
|
||||||
|
placeholder="ex.: acme"
|
||||||
|
className="font-mono"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="t-plan">Plano</FieldLabel>
|
||||||
|
<Select id="t-plan" value={planId} onChange={(e) => setPlanId(e.target.value)} disabled={pending}>
|
||||||
|
{plans.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="t-legal">Razão social</FieldLabel>
|
||||||
|
<Input id="t-legal" value={legalName} onChange={(e) => setLegalName(e.target.value)} placeholder="Ex.: Acme Call Center LTDA" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="t-trade">Nome fantasia (opcional)</FieldLabel>
|
||||||
|
<Input id="t-trade" value={tradeName} onChange={(e) => setTradeName(e.target.value)} placeholder="Ex.: Acme Call Center" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:w-64">
|
||||||
|
<FieldLabel htmlFor="t-tax">CNPJ/CPF (opcional)</FieldLabel>
|
||||||
|
<Input id="t-tax" value={taxId} onChange={(e) => setTaxId(e.target.value)} className="font-mono" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<p className="text-sm font-medium text-foreground">Primeiro usuário (Tenant Admin)</p>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="t-admin-name">Nome</FieldLabel>
|
||||||
|
<Input id="t-admin-name" value={adminName} onChange={(e) => setAdminName(e.target.value)} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="t-admin-email">E-mail</FieldLabel>
|
||||||
|
<Input id="t-admin-email" type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 className="flex justify-end gap-3">
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Criando…" : "Criar tenant"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/frontend/src/app/platform/clientes/tenants/new/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { Plan } from "@/lib/platform-types";
|
||||||
|
import { NewTenantForm } from "./form";
|
||||||
|
|
||||||
|
export default async function NewTenantPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const plans = await apiFetch<Plan[]>("/plans", session.accessToken);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Novo tenant</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Cria o cliente e o primeiro usuário (Tenant Admin) na mesma ação.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NewTenantForm plans={plans} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/platform/clientes/tenants/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { requireSession } from "@/lib/session";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import type { Tenant } from "@/lib/platform-types";
|
||||||
|
import { TenantsView } from "./tenants-view";
|
||||||
|
|
||||||
|
export default async function TenantsPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const tenants = await apiFetch<Tenant[]>("/tenants", session.accessToken);
|
||||||
|
return <TenantsView tenants={tenants} />;
|
||||||
|
}
|
||||||
101
apps/frontend/src/app/platform/clientes/tenants/tenants-view.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Building2, Plus, Search } from "lucide-react";
|
||||||
|
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Pill } from "@/components/ui/pill";
|
||||||
|
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { TENANT_STATUS_LABELS, type Tenant } from "@/lib/platform-types";
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, "accent" | "neutral"> = {
|
||||||
|
ACTIVE: "accent",
|
||||||
|
TRIAL: "neutral",
|
||||||
|
SUSPENDED: "neutral",
|
||||||
|
PAST_DUE: "neutral",
|
||||||
|
CANCELLED: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TenantsView({ tenants }: { tenants: Tenant[] }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return tenants;
|
||||||
|
return tenants.filter((t) => t.legalName.toLowerCase().includes(q) || t.code.toLowerCase().includes(q));
|
||||||
|
}, [tenants, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Tenants</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Clientes da plataforma (agente.md secao 29, 141) — criar um tenant já cria o primeiro usuário
|
||||||
|
(Tenant Admin) na mesma ação, sem esse usuário o tenant fica inacessível.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/platform/clientes/tenants/new">
|
||||||
|
<Plus className="h-4 w-4" aria-hidden />
|
||||||
|
Novo tenant
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader title="Tenants cadastrados" description={`${tenants.length} tenant(s) na plataforma`} />
|
||||||
|
<div className="border-b border-border px-5 py-3">
|
||||||
|
<div className="relative w-full max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||||
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Buscar tenant…" className="pl-8" aria-label="Buscar tenant" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={tenants.length > 0 ? "Nenhum tenant bate com essa busca" : "Nenhum tenant cadastrado ainda"}
|
||||||
|
description={tenants.length > 0 ? "Tente outro termo." : "Crie o primeiro cliente da plataforma."}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<TR>
|
||||||
|
<TH>Tenant</TH>
|
||||||
|
<TH>Código</TH>
|
||||||
|
<TH>Plano</TH>
|
||||||
|
<TH>Status</TH>
|
||||||
|
<TH>Usuários</TH>
|
||||||
|
<TH>Criado</TH>
|
||||||
|
</TR>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{rows.map((t) => (
|
||||||
|
<TR key={t.id}>
|
||||||
|
<TD>
|
||||||
|
<Link
|
||||||
|
href={`/platform/clientes/tenants/${t.id}`}
|
||||||
|
className="flex items-center gap-2 font-medium text-foreground underline-offset-4 hover:text-primary hover:underline focus-visible:underline"
|
||||||
|
>
|
||||||
|
<Building2 className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
{t.legalName}
|
||||||
|
</Link>
|
||||||
|
</TD>
|
||||||
|
<TD className="font-mono text-xs text-muted-foreground">{t.code}</TD>
|
||||||
|
<TD className="text-muted-foreground">{t.plan.name}</TD>
|
||||||
|
<TD>
|
||||||
|
<Pill tone={STATUS_TONE[t.status] ?? "neutral"}>{TENANT_STATUS_LABELS[t.status] ?? t.status}</Pill>
|
||||||
|
</TD>
|
||||||
|
<TD className="font-mono tabular-nums text-muted-foreground">{t.memberCount}</TD>
|
||||||
|
<TD className="text-muted-foreground">{formatDate(t.createdAt)}</TD>
|
||||||
|
</TR>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,20 @@ export const PLATFORM_NAV: NavSection[] = [
|
|||||||
{
|
{
|
||||||
label: "Clientes",
|
label: "Clientes",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
children: [{ label: "Tenants" }, { label: "Planos" }, { label: "Assinaturas" }, { label: "Quotas" }],
|
children: [
|
||||||
|
{
|
||||||
|
label: "Tenants",
|
||||||
|
href: "/platform/clientes/tenants",
|
||||||
|
description: "Clientes da plataforma — criar cria também o primeiro usuário",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Planos",
|
||||||
|
href: "/platform/clientes/planos",
|
||||||
|
description: "Catálogo de limites e recursos por plano",
|
||||||
|
},
|
||||||
|
{ label: "Assinaturas" },
|
||||||
|
{ label: "Quotas" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Billing",
|
label: "Billing",
|
||||||
|
|||||||
61
apps/frontend/src/lib/platform-types.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
maxExtensions: number | null;
|
||||||
|
maxAgents: number | null;
|
||||||
|
maxTrunks: number | null;
|
||||||
|
maxQueues: number | null;
|
||||||
|
maxCampaigns: number | null;
|
||||||
|
maxCps: number | null;
|
||||||
|
maxConcurrentCalls: number | null;
|
||||||
|
maxDailyCalls: number | null;
|
||||||
|
maxMonthlyCalls: number | null;
|
||||||
|
maxRecordingStorageGb: number | null;
|
||||||
|
recordingRetentionDays: number | null;
|
||||||
|
transcriptionRetentionDays: number | null;
|
||||||
|
recordingEnabled: boolean;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiTranscriptionEnabled: boolean;
|
||||||
|
aiAnalysisEnabled: boolean;
|
||||||
|
apiAccessEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
slug: string;
|
||||||
|
legalName: string;
|
||||||
|
tradeName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
status: "TRIAL" | "ACTIVE" | "SUSPENDED" | "PAST_DUE" | "CANCELLED";
|
||||||
|
timezone: string;
|
||||||
|
locale: string;
|
||||||
|
billingCurrency: string;
|
||||||
|
planId: string;
|
||||||
|
plan: { id: string; key: string; name: string };
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TENANT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
TRIAL: "Trial",
|
||||||
|
ACTIVE: "Ativo",
|
||||||
|
SUSPENDED: "Suspenso",
|
||||||
|
PAST_DUE: "Inadimplente",
|
||||||
|
CANCELLED: "Cancelado",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAN_LIMIT_FIELDS: { key: keyof Plan; label: string }[] = [
|
||||||
|
{ key: "maxExtensions", label: "Ramais" },
|
||||||
|
{ key: "maxAgents", label: "Agentes" },
|
||||||
|
{ key: "maxTrunks", label: "Troncos" },
|
||||||
|
{ key: "maxQueues", label: "Filas" },
|
||||||
|
{ key: "maxCampaigns", label: "Campanhas" },
|
||||||
|
{ key: "maxCps", label: "CPS" },
|
||||||
|
{ key: "maxConcurrentCalls", label: "Chamadas simultâneas" },
|
||||||
|
{ key: "maxDailyCalls", label: "Chamadas/dia" },
|
||||||
|
{ key: "maxMonthlyCalls", label: "Chamadas/mês" },
|
||||||
|
{ key: "maxRecordingStorageGb", label: "Armazenamento (GB)" },
|
||||||
|
];
|
||||||