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
This commit is contained in:
2026-08-29 19:50:12 -03:00
parent 3479dbd007
commit 2c1269a83a
27 changed files with 1462 additions and 1 deletions

View 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) };
}
}

View 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} />;
}

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View 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) };
}
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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 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>
);
}

View File

@@ -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",

View 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)" },
];