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
@@ -20,6 +20,8 @@ import { AIModule } from "./ai/ai.module";
|
||||
import { QualityModule } from "./quality/quality.module";
|
||||
import { PlatformModule } from "./platform/platform.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { TenantsModule } from "./tenants/tenants.module";
|
||||
import { PlansModule } from "./plans/plans.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,6 +46,8 @@ import { BillingModule } from "./billing/billing.module";
|
||||
QualityModule,
|
||||
PlatformModule,
|
||||
BillingModule,
|
||||
TenantsModule,
|
||||
PlansModule,
|
||||
],
|
||||
})
|
||||
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",
|
||||
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",
|
||||
|
||||
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)" },
|
||||
];
|
||||