feat(frontend): IA > Scorecards, Prompts, Configurações

Três telas novas fecham quase todo o menu IA: Scorecards (critérios
ponderados, array dinâmico como no Dialplan), Prompts (templates com
versionamento — editar = criar versão + ativar numa ação só, já que não
existe endpoint pra listar histórico), e Configurações (providers BYOK +
modelos). Chave de API nunca é reexibida em texto puro, nem uma vez — só
preview mascarado, mesmo na resposta de criação.

Novo componente Textarea em components/ui/input.tsx (mesmo estilo de
Input/Select) — primeira tela que precisa de texto longo.

Testado ponta a ponta contra a API real: scorecard, template de prompt
(v1 -> editar -> v2 ativada), provider BYOK + modelo referenciando ele.
Smoke test de regressão nas 16 telas anteriores do tenant + platform,
todas 200. Typecheck limpo no monorepo inteiro.

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 16:30:33 -03:00
parent 4d112fcea0
commit 75212cafe6
23 changed files with 1150 additions and 3 deletions

42
TODO.md
View File

@@ -1054,6 +1054,48 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
- [ ] Relatórios > Consumo (dashboard de quotas, secao 139) — ainda "em - [ ] Relatórios > Consumo (dashboard de quotas, secao 139) — ainda "em
breve" breve"
## PHASE 31 — Frontend: IA > Scorecards, Prompts, Configurações
(agente.md secao 96-103, 114-118)
- [x] IA > Scorecards (`/app/ia/scorecards`): criar (nome + lista dinâmica
de critérios com peso, mesmo padrão de array dinâmico do Dialplan),
listar em cards (um por scorecard, itens com badge de peso),
remover.
- [x] IA > Prompts (`/app/ia/prompts`): criar template (finalidade
Análise/Scorecard + conteúdo v1), listar com o conteúdo da versão
ativa visível. "Editar" o conteúdo dispara `POST .../versions` +
`POST .../versions/:id/activate` numa ação só (create-então-ativa) —
não existe endpoint pra listar o histórico de versões, então a UI
não finge ter uma linha do tempo que não dá pra buscar; só o
conteúdo ativo é mostrado. Templates `Global` (da plataforma,
`tenantId=null`, RLS híbrida já resolve a união sem filtro no
client) aparecem mas sem botão de editar — só quem tem role de
plataforma pode alterar, e o backend rejeitaria com 403 mesmo que
o botão existisse.
- [x] IA > Configurações (`/app/ia/configuracoes`): providers (BYOK,
`scope: "TENANT"` sempre — a tela nem oferece GLOBAL, que já é
exclusivo de platform admin no backend) e modelos (referenciando um
provider, capacidades via checkboxes). Chave de API só aparece como
preview (`...xxxxxxxx`) mesmo na resposta de criação — nunca
reexibida em texto puro, nem uma vez (diferente do padrão "revela
uma vez" de Ramais; aqui a API nunca devolve o valor puro, ponto).
- [x] `Textarea` novo em `components/ui/input.tsx` (mesmo estilo de
`Input`/`Select`) — primeira tela que precisa de texto longo
(conteúdo de prompt).
- [x] Testado ponta a ponta contra a API real (tenant Acme): scorecard
"Atendimento" com critério "Saudação" criado e listado; template
"Análise padrão" criado (v1) → editado → v2 ativada e refletida na
tela; provider "OpenAI da Acme" (BYOK) criado com preview de chave
mascarada → modelo "GPT-4o mini" criado referenciando o provider,
com capacidade "Transcrição". Smoke test de regressão nas 16 telas
anteriores do tenant + platform, todas 200.
- [ ] Scorecards: sem edição de critérios existentes (só criar scorecard
inteiro ou remover) — mesma limitação de "só create/list/delete"
já documentada em outras telas simples
- [ ] IA > Configurações não distingue custo por modelo (`inputCost`/
`outputCost`/`audioCost` existem no backend, sem campo no form) —
decisão de escopo pra manter o formulário enxuto nesta primeira
versão
--- ---
## Riscos conhecidos ## Riscos conhecidos

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -0,0 +1,93 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { AIModelConfig, AIProviderConfig } from "@/lib/ai-config-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 CreateProviderInput {
providerType: string;
name: string;
apiKey: string;
baseUrl?: string;
organization?: string;
project?: string;
}
/** Sempre `scope: "TENANT"` (BYOK) — esta tela é do tenant, GLOBAL é
* exclusivo de platform admin (o backend rejeita com 403 de qualquer
* forma, mas nem oferece a opção aqui). */
export async function createProvider(
input: CreateProviderInput,
): Promise<{ ok: true; provider: AIProviderConfig } | { ok: false; error: string }> {
const session = await requireSession();
try {
const provider = await apiFetch<AIProviderConfig>("/ai/providers", session.accessToken, {
method: "POST",
body: JSON.stringify({ ...input, scope: "TENANT" }),
});
revalidatePath("/app/ia/configuracoes");
return { ok: true, provider };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function deleteProvider(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await requireSession();
try {
await apiFetch<void>(`/ai/providers/${id}`, session.accessToken, { method: "DELETE" });
revalidatePath("/app/ia/configuracoes");
return { ok: true };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export interface CreateModelInput {
providerId: string;
externalModelId: string;
displayName: string;
capabilities: string[];
}
export async function createModel(
input: CreateModelInput,
): Promise<{ ok: true; model: AIModelConfig } | { ok: false; error: string }> {
const session = await requireSession();
try {
const model = await apiFetch<AIModelConfig>("/ai/models", session.accessToken, {
method: "POST",
body: JSON.stringify(input),
});
revalidatePath("/app/ia/configuracoes");
return { ok: true, model };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function deleteModel(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await requireSession();
try {
await apiFetch<void>(`/ai/models/${id}`, session.accessToken, { method: "DELETE" });
revalidatePath("/app/ia/configuracoes");
return { ok: true };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}

View File

@@ -0,0 +1,354 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Bot, Cpu, Plus, Trash2, X } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import {
AI_CAPABILITIES,
AI_CAPABILITY_LABELS,
PROVIDER_TYPES,
type AIModelConfig,
type AIProviderConfig,
} from "@/lib/ai-config-types";
import { createModel, createProvider, deleteModel, deleteProvider } from "./actions";
export function ConfiguracoesView({ providers, models }: { providers: AIProviderConfig[]; models: AIModelConfig[] }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">IA Configurações</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Providers e modelos de IA (agente.md secao 96-103) os que aparecem com <Pill tone="accent">Global</Pill>{" "}
vêm da plataforma e não podem ser alterados por aqui; o resto é BYOK deste tenant. A chave de API nunca é
reexibida depois de salva.
</p>
</div>
<ProvidersPanel providers={providers} />
<ModelsPanel models={models} providers={providers} />
</div>
);
}
function ProvidersPanel({ providers }: { providers: AIProviderConfig[] }) {
const [showForm, setShowForm] = useState(false);
return (
<Panel>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">Providers</h2>
<p className="mt-0.5 text-xs text-muted-foreground">{providers.length} provider(s) visível(is) deste tenant</p>
</div>
<Button type="button" size="sm" onClick={() => setShowForm((s) => !s)}>
{showForm ? <X className="h-3.5 w-3.5" aria-hidden /> : <Plus className="h-3.5 w-3.5" aria-hidden />}
{showForm ? "Cancelar" : "Novo provider"}
</Button>
</div>
{showForm && <NewProviderForm onDone={() => setShowForm(false)} />}
{providers.length === 0 ? (
<EmptyState title="Nenhum provider disponível ainda" description="Cadastre uma chave de API (BYOK) pra habilitar transcrição/análise." />
) : (
<Table>
<THead>
<TR>
<TH>Nome</TH>
<TH>Tipo</TH>
<TH>Chave</TH>
<TH>Escopo</TH>
<TH>
<span className="sr-only">Ações</span>
</TH>
</TR>
</THead>
<TBody>
{providers.map((p) => (
<TR key={p.id}>
<TD>
<span className="flex items-center gap-2 font-medium text-foreground">
<Bot className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{p.name}
</span>
</TD>
<TD className="font-mono text-muted-foreground">{p.providerType}</TD>
<TD className="font-mono text-xs text-muted-foreground">{p.apiKeyPreview}</TD>
<TD>
<Pill tone={p.scope === "GLOBAL" ? "accent" : "neutral"}>{p.scope === "GLOBAL" ? "Global" : "Deste tenant"}</Pill>
</TD>
<TD>{p.scope === "TENANT" && <DeleteButton onDelete={() => deleteProvider(p.id)} label={p.name} />}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
);
}
function NewProviderForm({ onDone }: { onDone: () => void }) {
const [providerType, setProviderType] = useState<string>(PROVIDER_TYPES[0]);
const [name, setName] = useState("");
const [apiKey, setApiKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim() || apiKey.trim().length < 10) {
setError("Nome obrigatório e chave de API com pelo menos 10 caracteres.");
return;
}
startTransition(async () => {
const result = await createProvider({ providerType, name: name.trim(), apiKey: apiKey.trim() });
if (!result.ok) {
setError(result.error);
return;
}
onDone();
});
}
return (
<div className="border-b border-border p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<FieldLabel htmlFor="pr-type">Tipo</FieldLabel>
<Select id="pr-type" value={providerType} onChange={(e) => setProviderType(e.target.value)} disabled={pending}>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="pr-name">Nome</FieldLabel>
<Input id="pr-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: OpenAI da Acme" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="pr-key">Chave de API</FieldLabel>
<Input id="pr-key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-…" disabled={pending} />
</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>
)}
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={pending}>
{pending ? "Salvando…" : "Salvar provider"}
</Button>
</div>
</form>
</div>
);
}
function ModelsPanel({ models, providers }: { models: AIModelConfig[]; providers: AIProviderConfig[] }) {
const [showForm, setShowForm] = useState(false);
const providersById = useMemo(() => Object.fromEntries(providers.map((p) => [p.id, p])), [providers]);
return (
<Panel>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-foreground">Modelos</h2>
<p className="mt-0.5 text-xs text-muted-foreground">{models.length} modelo(s) configurado(s)</p>
</div>
<Button type="button" size="sm" onClick={() => setShowForm((s) => !s)} disabled={providers.length === 0}>
{showForm ? <X className="h-3.5 w-3.5" aria-hidden /> : <Plus className="h-3.5 w-3.5" aria-hidden />}
{showForm ? "Cancelar" : "Novo modelo"}
</Button>
</div>
{providers.length === 0 && !showForm && (
<p className="px-5 py-3 text-sm text-muted-foreground">Cadastre um provider primeiro pra poder configurar um modelo.</p>
)}
{showForm && <NewModelForm providers={providers} onDone={() => setShowForm(false)} />}
{models.length === 0 ? (
<EmptyState title="Nenhum modelo configurado ainda" description="Configure o primeiro modelo a partir de um provider." />
) : (
<Table>
<THead>
<TR>
<TH>Nome</TH>
<TH>Provider</TH>
<TH>Modelo (ID externo)</TH>
<TH>Capacidades</TH>
<TH>
<span className="sr-only">Ações</span>
</TH>
</TR>
</THead>
<TBody>
{models.map((m) => (
<TR key={m.id}>
<TD>
<span className="flex items-center gap-2 font-medium text-foreground">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{m.displayName}
</span>
</TD>
<TD className="text-muted-foreground">{providersById[m.providerId]?.name ?? m.providerId}</TD>
<TD className="font-mono text-muted-foreground">{m.externalModelId}</TD>
<TD>
<span className="flex flex-wrap gap-1">
{m.capabilities.map((c) => (
<Pill key={c}>{AI_CAPABILITY_LABELS[c] ?? c}</Pill>
))}
</span>
</TD>
<TD>
<DeleteButton onDelete={() => deleteModel(m.id)} label={m.displayName} />
</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
);
}
function NewModelForm({ providers, onDone }: { providers: AIProviderConfig[]; onDone: () => void }) {
const [providerId, setProviderId] = useState(providers[0]?.id ?? "");
const [externalModelId, setExternalModelId] = useState("");
const [displayName, setDisplayName] = useState("");
const [capabilities, setCapabilities] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function toggleCapability(cap: string) {
setCapabilities((prev) => (prev.includes(cap) ? prev.filter((c) => c !== cap) : [...prev, cap]));
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!providerId || !externalModelId.trim() || !displayName.trim()) {
setError("Provider, ID do modelo e nome de exibição são obrigatórios.");
return;
}
startTransition(async () => {
const result = await createModel({
providerId,
externalModelId: externalModelId.trim(),
displayName: displayName.trim(),
capabilities,
});
if (!result.ok) {
setError(result.error);
return;
}
onDone();
});
}
return (
<div className="border-b border-border p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<FieldLabel htmlFor="md-provider">Provider</FieldLabel>
<Select id="md-provider" value={providerId} onChange={(e) => setProviderId(e.target.value)} disabled={pending}>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="md-external">ID do modelo (externo)</FieldLabel>
<Input id="md-external" value={externalModelId} onChange={(e) => setExternalModelId(e.target.value)} placeholder="gpt-4o-mini" className="font-mono" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="md-name">Nome de exibição</FieldLabel>
<Input id="md-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="GPT-4o mini" disabled={pending} />
</div>
</div>
<div>
<FieldLabel htmlFor="md-cap-0">Capacidades</FieldLabel>
<div className="flex flex-wrap gap-3">
{AI_CAPABILITIES.map((cap, i) => (
<label key={cap} className="flex items-center gap-1.5 text-sm text-foreground">
<input
id={i === 0 ? "md-cap-0" : undefined}
type="checkbox"
checked={capabilities.includes(cap)}
onChange={() => toggleCapability(cap)}
disabled={pending}
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
{AI_CAPABILITY_LABELS[cap]}
</label>
))}
</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>
)}
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={pending}>
{pending ? "Salvando…" : "Salvar modelo"}
</Button>
</div>
</form>
</div>
);
}
function DeleteButton({ onDelete, label }: { onDelete: () => Promise<{ ok: true } | { ok: false; error: string }>; label: string }) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onClick() {
if (!confirming) {
setConfirming(true);
return;
}
setError(null);
startTransition(async () => {
const result = await onDelete();
if (!result.ok) {
setError(result.error);
setConfirming(false);
return;
}
router.refresh();
});
}
return (
<div className="flex items-center justify-end gap-2">
{error && <span className="text-xs text-destructive">{error}</span>}
<Button
type="button"
variant={confirming ? "destructive" : "ghost"}
size="sm"
onClick={onClick}
disabled={pending}
aria-label={confirming ? `Confirmar remoção de ${label}` : `Remover ${label}`}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
{confirming ? "Confirmar" : ""}
</Button>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { AIModelConfig, AIProviderConfig } from "@/lib/ai-config-types";
import { ConfiguracoesView } from "./configuracoes-view";
export default async function ConfiguracoesPage() {
const session = await requireSession();
const [providers, models] = await Promise.all([
apiFetch<AIProviderConfig[]>("/ai/providers", session.accessToken),
apiFetch<AIModelConfig[]>("/ai/models", session.accessToken),
]);
return <ConfiguracoesView providers={providers} models={models} />;
}

View File

@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { AIPromptTemplate, AIPromptVersion } from "@/lib/prompt-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 CreatePromptTemplateInput {
purpose: "ANALYSIS" | "SCORECARD";
name: string;
content: string;
}
export async function createPromptTemplate(
input: CreatePromptTemplateInput,
): Promise<{ ok: true; template: AIPromptTemplate } | { ok: false; error: string }> {
const session = await requireSession();
try {
const template = await apiFetch<AIPromptTemplate>("/ai/prompt-templates", session.accessToken, {
method: "POST",
body: JSON.stringify(input),
});
revalidatePath("/app/ia/prompts");
return { ok: true, template };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
/** "Editar" o conteúdo = criar uma versão nova e ativá-la na mesma ação —
* o backend nunca edita versão in-place (secao 114-116), e não existe
* endpoint pra listar todo o histórico, então a UI não tenta mostrar um
* histórico que não dá pra buscar; só o conteúdo ativo importa aqui. */
export async function updatePromptContent(
templateId: string,
content: string,
): Promise<{ ok: true; template: AIPromptTemplate } | { ok: false; error: string }> {
const session = await requireSession();
try {
const version = await apiFetch<AIPromptVersion>(`/ai/prompt-templates/${templateId}/versions`, session.accessToken, {
method: "POST",
body: JSON.stringify({ content }),
});
const template = await apiFetch<AIPromptTemplate>(
`/ai/prompt-templates/${templateId}/versions/${version.id}/activate`,
session.accessToken,
{ method: "POST" },
);
revalidatePath("/app/ia/prompts");
return { ok: true, template };
} 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 { AIPromptTemplate } from "@/lib/prompt-types";
import { PromptsView } from "./prompts-view";
export default async function PromptsPage() {
const session = await requireSession();
const templates = await apiFetch<AIPromptTemplate[]>("/ai/prompt-templates", session.accessToken);
return <PromptsView templates={templates} />;
}

View File

@@ -0,0 +1,173 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { FileText, Plus, X } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, Select, Textarea, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { EmptyState } from "@/components/ui/table";
import { PROMPT_PURPOSE_LABELS, type AIPromptTemplate } from "@/lib/prompt-types";
import { createPromptTemplate, updatePromptContent } from "./actions";
export function PromptsView({ templates }: { templates: AIPromptTemplate[] }) {
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">Prompts de IA</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Templates usados na análise de chamada e na avaliação de scorecard (agente.md secao 114-116). O
conteúdo nunca é editado no lugar trocar o texto cria uma versão nova e a ativa. Templates{" "}
<Pill>Global</Pill> vêm da plataforma; quem tem role de plataforma pode alterá-los.
</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 template"}
</Button>
</div>
{showForm && <NewTemplateForm onDone={() => setShowForm(false)} />}
{templates.length === 0 ? (
<Panel>
<EmptyState title="Nenhum template cadastrado ainda" description="Crie o primeiro template de prompt." />
</Panel>
) : (
<div className="space-y-4">
{templates.map((t) => (
<TemplateCard key={t.id} template={t} />
))}
</div>
)}
</div>
);
}
function TemplateCard({ template }: { template: AIPromptTemplate }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [content, setContent] = useState(template.activeVersion?.content ?? "");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const isGlobal = template.tenantId === null;
function onSave() {
setError(null);
startTransition(async () => {
const result = await updatePromptContent(template.id, content);
if (!result.ok) {
setError(result.error);
return;
}
setEditing(false);
router.refresh();
});
}
return (
<Panel>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold text-foreground">{template.name}</h2>
<Pill>{PROMPT_PURPOSE_LABELS[template.purpose]}</Pill>
{isGlobal && <Pill tone="accent">Global</Pill>}
{template.activeVersion && <span className="text-xs text-muted-foreground">v{template.activeVersion.version}</span>}
</div>
{!isGlobal && !editing && (
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(true)}>
Editar conteúdo
</Button>
)}
</div>
<div className="p-5">
{editing ? (
<div className="space-y-3">
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={8} className="font-mono text-xs" disabled={pending} />
{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 como nova versão"}
</Button>
</div>
</div>
) : (
<pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
{template.activeVersion?.content || "Sem conteúdo ativo ainda."}
</pre>
)}
</div>
</Panel>
);
}
function NewTemplateForm({ onDone }: { onDone: () => void }) {
const [name, setName] = useState("");
const [purpose, setPurpose] = useState<"ANALYSIS" | "SCORECARD">("ANALYSIS");
const [content, setContent] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim() || !content.trim()) {
setError("Nome e conteúdo são obrigatórios.");
return;
}
startTransition(async () => {
const result = await createPromptTemplate({ purpose, name: name.trim(), content: content.trim() });
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="pt-name">Nome</FieldLabel>
<Input id="pt-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Análise padrão" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="pt-purpose">Finalidade</FieldLabel>
<Select id="pt-purpose" value={purpose} onChange={(e) => setPurpose(e.target.value as "ANALYSIS" | "SCORECARD")} disabled={pending}>
<option value="ANALYSIS">Análise de chamada</option>
<option value="SCORECARD">Avaliação de scorecard</option>
</Select>
</div>
</div>
<div>
<FieldLabel htmlFor="pt-content">Conteúdo (v1)</FieldLabel>
<Textarea id="pt-content" value={content} onChange={(e) => setContent(e.target.value)} rows={6} className="font-mono text-xs" 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 template"}
</Button>
</div>
</form>
</Panel>
);
}

View File

@@ -0,0 +1,59 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { QualityScorecard } from "@/lib/quality-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 CreateScorecardItemInput {
name: string;
weight?: number;
description?: string;
evaluationPrompt?: string;
}
export interface CreateScorecardInput {
name: string;
items: CreateScorecardItemInput[];
}
export async function createScorecard(
input: CreateScorecardInput,
): Promise<{ ok: true; scorecard: QualityScorecard } | { ok: false; error: string }> {
const session = await requireSession();
try {
const scorecard = await apiFetch<QualityScorecard>("/quality/scorecards", session.accessToken, {
method: "POST",
body: JSON.stringify(input),
});
revalidatePath("/app/ia/scorecards");
return { ok: true, scorecard };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function deleteScorecard(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await requireSession();
try {
await apiFetch<void>(`/quality/scorecards/${id}`, session.accessToken, { method: "DELETE" });
revalidatePath("/app/ia/scorecards");
return { ok: true };
} 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 { QualityScorecard } from "@/lib/quality-types";
import { ScorecardsView } from "./scorecards-view";
export default async function ScorecardsPage() {
const session = await requireSession();
const scorecards = await apiFetch<QualityScorecard[]>("/quality/scorecards", session.accessToken);
return <ScorecardsView scorecards={scorecards} />;
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ClipboardList, Plus, Trash2, 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 type { QualityScorecard } from "@/lib/quality-types";
import { createScorecard, deleteScorecard, type CreateScorecardItemInput } from "./actions";
export function ScorecardsView({ scorecards }: { scorecards: QualityScorecard[] }) {
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">Scorecards de QA</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Critérios ponderados que a IA avalia em toda chamada analisada (agente.md secao 117-118) sem lista
fixa, cada tenant define os seus. Editar um scorecard não muda avaliações feitas.
</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 scorecard"}
</Button>
</div>
{showForm && <NewScorecardForm onDone={() => setShowForm(false)} />}
{scorecards.length === 0 ? (
<Panel>
<EmptyState title="Nenhum scorecard cadastrado ainda" description="Crie o primeiro, ex.: Atendimento, Vendas." />
</Panel>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{scorecards.map((sc) => (
<ScorecardCard key={sc.id} scorecard={sc} />
))}
</div>
)}
</div>
);
}
function ScorecardCard({ scorecard }: { scorecard: QualityScorecard }) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onDelete() {
if (!confirming) {
setConfirming(true);
return;
}
setError(null);
startTransition(async () => {
const result = await deleteScorecard(scorecard.id);
if (!result.ok) {
setError(result.error);
setConfirming(false);
return;
}
router.refresh();
});
}
return (
<Panel>
<PanelHeader title={scorecard.name} description={`${scorecard.items.length} critério(s)`} />
<ul className="divide-y divide-border">
{scorecard.items.map((item) => (
<li key={item.id} className="flex items-center justify-between gap-3 px-5 py-3 text-sm">
<span className="flex items-center gap-2 text-foreground">
<ClipboardList className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{item.name}
</span>
<Pill>peso {item.weight}</Pill>
</li>
))}
</ul>
<div className="flex items-center justify-between gap-2 border-t border-border px-5 py-3">
{error && <span className="text-xs text-destructive">{error}</span>}
<span className="flex-1" />
<Button
type="button"
variant={confirming ? "destructive" : "ghost"}
size="sm"
onClick={onDelete}
disabled={pending}
aria-label={confirming ? `Confirmar remoção de ${scorecard.name}` : `Remover ${scorecard.name}`}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
{confirming ? "Confirmar" : "Remover"}
</Button>
</div>
</Panel>
);
}
function NewScorecardForm({ onDone }: { onDone: () => void }) {
const [name, setName] = useState("");
const [items, setItems] = useState<CreateScorecardItemInput[]>([{ name: "", weight: 1 }]);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function updateItem(index: number, patch: Partial<CreateScorecardItemInput>) {
setItems((prev) => prev.map((it, i) => (i === index ? { ...it, ...patch } : it)));
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Dê um nome ao scorecard.");
return;
}
const validItems = items.filter((it) => it.name.trim());
if (validItems.length === 0) {
setError("Adicione pelo menos um critério com nome.");
return;
}
startTransition(async () => {
const result = await createScorecard({
name: name.trim(),
items: validItems.map((it) => ({
name: it.name.trim(),
weight: it.weight,
description: it.description?.trim() || undefined,
evaluationPrompt: it.evaluationPrompt?.trim() || undefined,
})),
});
if (!result.ok) {
setError(result.error);
return;
}
onDone();
});
}
return (
<Panel className="p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div>
<FieldLabel htmlFor="sc-name">Nome do scorecard</FieldLabel>
<Input id="sc-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Atendimento" disabled={pending} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<FieldLabel htmlFor="sc-item-0">Critérios</FieldLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setItems((prev) => [...prev, { name: "", weight: 1 }])}
disabled={pending}
>
<Plus className="h-3.5 w-3.5" aria-hidden />
Adicionar critério
</Button>
</div>
<div className="space-y-2">
{items.map((item, i) => (
<div key={i} className="grid grid-cols-1 gap-2 rounded-md border border-border p-3 sm:grid-cols-[1fr_auto_auto]">
<Input
id={`sc-item-${i}`}
value={item.name}
onChange={(e) => updateItem(i, { name: e.target.value })}
placeholder="Ex.: Saudação"
disabled={pending}
/>
<Input
type="number"
min={0}
value={item.weight ?? 1}
onChange={(e) => updateItem(i, { weight: Number(e.target.value) })}
className="w-24"
aria-label={`Peso do critério ${i + 1}`}
disabled={pending}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setItems((prev) => prev.filter((_, idx) => idx !== i))}
disabled={pending || items.length <= 1}
aria-label={`Remover critério ${i + 1}`}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</Button>
<Input
value={item.description ?? ""}
onChange={(e) => updateItem(i, { description: e.target.value })}
placeholder="Descrição (opcional)"
className="sm:col-span-3"
disabled={pending}
/>
</div>
))}
</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>
)}
<div className="flex justify-end">
<Button type="submit" disabled={pending}>
{pending ? "Criando…" : "Criar scorecard"}
</Button>
</div>
</form>
</Panel>
);
}

View File

@@ -114,9 +114,21 @@ export const TENANT_NAV: NavSection[] = [
href: "/app/relatorios/ia", href: "/app/relatorios/ia",
description: "Dashboard de IA — score, sentimento, assuntos, compliance", description: "Dashboard de IA — score, sentimento, assuntos, compliance",
}, },
{ label: "Scorecards" }, {
{ label: "Prompts" }, label: "Scorecards",
{ label: "Configurações" }, href: "/app/ia/scorecards",
description: "Critérios ponderados que a IA avalia em toda chamada analisada",
},
{
label: "Prompts",
href: "/app/ia/prompts",
description: "Templates de prompt usados na análise e no scorecard",
},
{
label: "Configurações",
href: "/app/ia/configuracoes",
description: "Providers e modelos de IA — BYOK deste tenant",
},
], ],
}, },
{ {

View File

@@ -15,6 +15,20 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
); );
Input.displayName = "Input"; Input.displayName = "Input";
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"w-full rounded-md border border-input bg-surface px-3 py-2 text-sm text-foreground outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Textarea.displayName = "Textarea";
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>( export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
({ className, children, ...props }, ref) => ( ({ className, children, ...props }, ref) => (
<select <select

View File

@@ -0,0 +1,40 @@
export interface AIProviderConfig {
id: string;
scope: "GLOBAL" | "TENANT";
tenantId: string | null;
providerType: string;
name: string;
baseUrl: string | null;
organization: string | null;
project: string | null;
apiKeyPreview: string;
enabled: boolean;
createdAt: string;
}
export const PROVIDER_TYPES = ["openai", "anthropic"] as const;
export interface AIModelConfig {
id: string;
tenantId: string | null;
providerId: string;
externalModelId: string;
displayName: string;
capabilities: string[];
inputCost: number | null;
outputCost: number | null;
audioCost: number | null;
enabled: boolean;
createdAt: string;
}
export const AI_CAPABILITIES = ["TRANSCRIPTION", "DIARIZATION", "TEXT_ANALYSIS", "STRUCTURED_OUTPUT", "EMBEDDINGS", "REALTIME_AUDIO"] as const;
export const AI_CAPABILITY_LABELS: Record<string, string> = {
TRANSCRIPTION: "Transcrição",
DIARIZATION: "Diarização",
TEXT_ANALYSIS: "Análise de texto",
STRUCTURED_OUTPUT: "Saída estruturada",
EMBEDDINGS: "Embeddings",
REALTIME_AUDIO: "Áudio em tempo real",
};

View File

@@ -0,0 +1,21 @@
export interface AIPromptVersion {
id: string;
templateId: string;
version: number;
content: string;
createdAt: string;
}
export interface AIPromptTemplate {
id: string;
tenantId: string | null;
purpose: "ANALYSIS" | "SCORECARD";
name: string;
activeVersionId: string | null;
activeVersion: AIPromptVersion | null;
}
export const PROMPT_PURPOSE_LABELS: Record<string, string> = {
ANALYSIS: "Análise de chamada",
SCORECARD: "Avaliação de scorecard",
};

View File

@@ -0,0 +1,14 @@
export interface QualityScorecardItem {
id: string;
name: string;
weight: number;
description: string | null;
evaluationPrompt: string | null;
}
export interface QualityScorecard {
id: string;
name: string;
enabled: boolean;
items: QualityScorecardItem[];
}