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:
70
apps/frontend/src/app/platform/clientes/planos/actions.ts
Normal file
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
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
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
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
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
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
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
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
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)" },
|
||||
];
|
||||
Reference in New Issue
Block a user