feat(agents): widget de status na topbar (entrar/pausar/retomar/sair)

Achado real reportado pelo usuário: "não achei como deixar o agente
online, tem algum perfil ou tipo de usuario especifico?" — não era
permissão nenhuma. POST /agents/me/login|pause|resume|logout sempre
funcionaram sem exigir permissão (só JwtAuthGuard, resolvem "meu
agente" pelo userId do próprio token). O problema: nenhuma tela chamava
esses endpoints — só existia o CRUD admin de Agentes (criar o vínculo
usuário+ramal), nunca um controle pro próprio usuário logado.

Adicionado GET /agents/me (404 = usuário sem Agent neste tenant, não um
erro — é assim que o widget decide se aparece) e GET /agents/me/pause-
reasons (motivos de pausa sem exigir agents.view, que listaria TODOS os
agentes do tenant — permissão que o role "agent" nunca teve e não devia
ganhar só pra isso).

AgentStatusWidget na topbar (visível em qualquer página do app do
tenant) só aparece pra quem tem Agent vinculado: Offline -> Entrar ->
Disponível -> Pausar (escolhe motivo) -> Em pausa -> Retomar/Sair.

Achado real construindo o widget: as Server Actions de login/logout/
pause/resume exportadas como arrow function que só repassavam
argumentos quebravam em runtime ("Server Actions must be async
functions") — o mesmo bug já documentado antes nesta sessão (wizard de
campanhas). Corrigido declarando como async function de verdade.

Testado ponta a ponta com Playwright, logado como um agente de teste
real (usuário novo, role "agent", Agent vinculado a um ramal real): o
fluxo completo (entrar/pausar/retomar/sair) funciona pela tela, sem
nenhuma chamada de API manual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 20:13:29 -03:00
parent 26cc79cec2
commit 1b05d93c48
8 changed files with 289 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ import {
Body, Body,
Controller, Controller,
ForbiddenException, ForbiddenException,
Get,
NotFoundException, NotFoundException,
Post, Post,
UseGuards, UseGuards,
@@ -35,6 +36,41 @@ async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userI
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller("agents/me") @Controller("agents/me")
export class AgentsMeController { export class AgentsMeController {
/**
* Achado real reportado pelo usuário: "não achei como deixar o agente
* online" — os endpoints de login/pausa/logout sempre existiram, mas
* não tinha nenhum jeito do frontend saber SE o usuário logado tem um
* Agent vinculado (pra mostrar o controle) nem qual o estado atual.
* 404 aqui = usuário sem Agent neste tenant, não um erro — é assim que
* o widget da topbar decide se aparece ou não.
*/
@Get()
async me(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
return {
id: agent.id,
name: agent.name,
state: agent.state,
enabled: agent.enabled,
hasExtension: agent.extensionId != null,
};
}
/** Motivos de pausa pro próprio agente escolher — sem exigir
* `agents.view` (que listaria TODOS os agentes do tenant, permissão
* que o role "agent" nunca precisou ter até aqui). */
@Get("pause-reasons")
async pauseReasons(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
return withTenantContext(prisma, tenantId, (tx) =>
tx.pauseReason.findMany({ where: { tenantId, enabled: true }, orderBy: { name: "asc" } }),
);
}
/** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal, /** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal,
* cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */ * cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */
@Post("login") @Post("login")

View File

@@ -1,8 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
import { TenantSidebar } from "@/components/tenant-shell/tenant-sidebar"; import { TenantSidebar } from "@/components/tenant-shell/tenant-sidebar";
import { TenantTopbar } from "@/components/tenant-shell/tenant-topbar"; import { TenantTopbar } from "@/components/tenant-shell/tenant-topbar";
import type { MyAgent, PauseReason } from "@/lib/callcenter-types";
interface Me { interface Me {
id: string; id: string;
@@ -22,11 +23,33 @@ export default async function TenantAppLayout({ children }: { children: React.Re
// direto aqui — /select-tenant resolve os dois casos antes de chegar. // direto aqui — /select-tenant resolve os dois casos antes de chegar.
if (!me.tenant) redirect(me.isPlatformUser ? "/platform" : "/select-tenant"); if (!me.tenant) redirect(me.isPlatformUser ? "/platform" : "/select-tenant");
// Achado real reportado pelo usuário: "não achei como deixar o agente
// online" — /agents/me devolve 404 pra quem não tem Agent vinculado
// (a maioria dos usuários — admin, supervisor sem ramal próprio etc.),
// que é o caso normal, não um erro: o widget da topbar só aparece pra
// quem tem Agent de verdade.
let myAgent: MyAgent | null = null;
let pauseReasons: PauseReason[] = [];
try {
myAgent = await apiFetch<MyAgent>("/agents/me", session.accessToken);
pauseReasons = await apiFetch<PauseReason[]>("/agents/me/pause-reasons", session.accessToken);
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) {
console.error("falha ao buscar agente do usuario logado", err);
}
}
return ( return (
<div className="flex h-dvh overflow-hidden bg-background"> <div className="flex h-dvh overflow-hidden bg-background">
<TenantSidebar railLabel={me.tenant.name} permissionKeys={me.permissionKeys} /> <TenantSidebar railLabel={me.tenant.name} permissionKeys={me.permissionKeys} />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<TenantTopbar fallbackTitle={me.tenant.name} userLabel={me.email} permissionKeys={me.permissionKeys} /> <TenantTopbar
fallbackTitle={me.tenant.name}
userLabel={me.email}
permissionKeys={me.permissionKeys}
myAgent={myAgent}
pauseReasons={pauseReasons}
/>
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main> <main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,17 @@ import { ThemeToggle } from "../platform-shell/theme-toggle";
import { MobileNavDrawer } from "./mobile-nav-drawer"; import { MobileNavDrawer } from "./mobile-nav-drawer";
import { getPageMeta, type NavSection } from "./nav-types"; import { getPageMeta, type NavSection } from "./nav-types";
export function Topbar({ items, fallbackTitle, userLabel }: { items: NavSection[]; fallbackTitle: string; userLabel: string }) { export function Topbar({
items,
fallbackTitle,
userLabel,
rightExtra,
}: {
items: NavSection[];
fallbackTitle: string;
userLabel: string;
rightExtra?: React.ReactNode;
}) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { title, description } = getPageMeta(items, pathname, fallbackTitle); const { title, description } = getPageMeta(items, pathname, fallbackTitle);
@@ -28,6 +38,8 @@ export function Topbar({ items, fallbackTitle, userLabel }: { items: NavSection[
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2 sm:gap-4"> <div className="flex shrink-0 items-center gap-2 sm:gap-4">
{rightExtra}
{rightExtra && <div className="hidden h-6 w-px bg-border sm:block" aria-hidden />}
<a <a
href="https://www.handix.com.br" href="https://www.handix.com.br"
target="_blank" target="_blank"

View File

@@ -0,0 +1,51 @@
"use server";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
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.";
}
type ActionResult = { ok: true; state: string } | { ok: false; error: string };
async function callAgentMe(path: string, body?: unknown): Promise<ActionResult> {
const session = await requireSession();
try {
const res = await apiFetch<{ state: string }>(`/agents/me${path}`, session.accessToken, {
method: "POST",
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { ok: true, state: res.state };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
// Server Actions exportadas como arrow function que só repassam
// argumentos pra outra função quebram em runtime ("Server Actions must
// be async functions") — achado real já documentado nesta sessão
// (mesmo bug do wizard de campanhas). Precisam ser `async function` de
// verdade, mesmo só repassando.
export async function agentLogin(): Promise<ActionResult> {
return callAgentMe("/login");
}
export async function agentLogout(): Promise<ActionResult> {
return callAgentMe("/logout");
}
export async function agentResume(): Promise<ActionResult> {
return callAgentMe("/resume");
}
export async function agentPause(pauseReasonId: string): Promise<ActionResult> {
return callAgentMe("/pause", { pauseReasonId });
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useState, useTransition } from "react";
import { Headset, Pause, Play, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { AGENT_STATE_LABELS, type MyAgent, type PauseReason } from "@/lib/callcenter-types";
import { agentLogin, agentLogout, agentPause, agentResume } from "./agent-me-actions";
/**
* Achado real reportado pelo usuário: "não achei como deixar o agente
* online" — os endpoints /agents/me/* sempre existiram, mas nenhuma tela
* chamava eles. Widget na topbar (visível em qualquer página do app do
* tenant), só aparece pra quem tem um Agent vinculado neste tenant
* (`initialAgent` null = usuário sem Agent, nada renderizado).
*/
export function AgentStatusWidget({ initialAgent, pauseReasons }: { initialAgent: MyAgent | null; pauseReasons: PauseReason[] }) {
const [agent, setAgent] = useState(initialAgent);
const [showPausePicker, setShowPausePicker] = useState(false);
const [pauseReasonId, setPauseReasonId] = useState(pauseReasons[0]?.id ?? "");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
if (!agent) return null;
function runAction(action: () => Promise<{ ok: true; state: string } | { ok: false; error: string }>) {
setError(null);
startTransition(async () => {
const result = await action();
if (!result.ok) {
setError(result.error);
return;
}
setAgent((prev) => (prev ? { ...prev, state: result.state } : prev));
setShowPausePicker(false);
});
}
const isOffline = agent.state === "OFFLINE";
const isPaused = agent.state === "PAUSED";
const isAvailable = agent.state === "AVAILABLE";
return (
<div className="flex items-center gap-2">
<Headset className="h-4 w-4 text-muted-foreground" aria-hidden />
<Pill tone={isAvailable ? "accent" : "neutral"}>{AGENT_STATE_LABELS[agent.state] ?? agent.state}</Pill>
{isOffline && !agent.hasExtension && (
<span className="text-xs text-destructive">Sem ramal configurado</span>
)}
{isOffline && agent.hasExtension && (
<Button type="button" size="sm" variant="ghost" onClick={() => runAction(agentLogin)} disabled={pending}>
<Play className="h-3.5 w-3.5" aria-hidden /> Entrar
</Button>
)}
{isAvailable && (
<>
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPausePicker((s) => !s)} disabled={pending}>
<Pause className="h-3.5 w-3.5" aria-hidden /> Pausar
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => runAction(agentLogout)} disabled={pending}>
<LogOut className="h-3.5 w-3.5" aria-hidden /> Sair
</Button>
</>
)}
{isPaused && (
<>
<Button type="button" size="sm" variant="ghost" onClick={() => runAction(agentResume)} disabled={pending}>
<Play className="h-3.5 w-3.5" aria-hidden /> Retomar
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => runAction(agentLogout)} disabled={pending}>
<LogOut className="h-3.5 w-3.5" aria-hidden /> Sair
</Button>
</>
)}
{showPausePicker && (
<div className="flex items-center gap-1.5">
<Select className="h-8 w-40 text-xs" value={pauseReasonId} onChange={(e) => setPauseReasonId(e.target.value)} disabled={pending}>
{pauseReasons.length === 0 && <option value="">Nenhum motivo cadastrado</option>}
{pauseReasons.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
<Button
type="button"
size="sm"
onClick={() => pauseReasonId && runAction(() => agentPause(pauseReasonId))}
disabled={pending || !pauseReasonId}
>
Confirmar
</Button>
</div>
)}
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
);
}

View File

@@ -2,17 +2,30 @@
import { Topbar } from "@/components/shell/topbar"; import { Topbar } from "@/components/shell/topbar";
import { filterNavByPermissions } from "@/components/shell/nav-types"; import { filterNavByPermissions } from "@/components/shell/nav-types";
import type { MyAgent, PauseReason } from "@/lib/callcenter-types";
import { TENANT_NAV } from "./nav-data"; import { TENANT_NAV } from "./nav-data";
import { AgentStatusWidget } from "./agent-status-widget";
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */ /** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
export function TenantTopbar({ export function TenantTopbar({
fallbackTitle, fallbackTitle,
userLabel, userLabel,
permissionKeys, permissionKeys,
myAgent,
pauseReasons,
}: { }: {
fallbackTitle: string; fallbackTitle: string;
userLabel: string; userLabel: string;
permissionKeys: string[]; permissionKeys: string[];
myAgent: MyAgent | null;
pauseReasons: PauseReason[];
}) { }) {
return <Topbar items={filterNavByPermissions(TENANT_NAV, permissionKeys)} fallbackTitle={fallbackTitle} userLabel={userLabel} />; return (
<Topbar
items={filterNavByPermissions(TENANT_NAV, permissionKeys)}
fallbackTitle={fallbackTitle}
userLabel={userLabel}
rightExtra={<AgentStatusWidget initialAgent={myAgent} pauseReasons={pauseReasons} />}
/>
);
} }

View File

@@ -47,10 +47,21 @@ export interface Trunk {
createdAt: string; createdAt: string;
} }
export const INBOUND_ROUTE_DESTINATION_TYPES = ["EXTENSION", "IVR", "QUEUE", "CALL_GROUP"] as const;
export type InboundRouteDestinationType = (typeof INBOUND_ROUTE_DESTINATION_TYPES)[number];
export const INBOUND_ROUTE_DESTINATION_TYPE_LABELS: Record<InboundRouteDestinationType, string> = {
EXTENSION: "Ramal",
IVR: "IVR",
QUEUE: "Fila",
CALL_GROUP: "Grupo de ramais",
};
export interface InboundRoute { export interface InboundRoute {
id: string; id: string;
didNumber: string; didNumber: string;
description: string | null; description: string | null;
destinationType: InboundRouteDestinationType;
destinationContext: string; destinationContext: string;
destinationNumber: string; destinationNumber: string;
enabled: boolean; enabled: boolean;
@@ -123,6 +134,14 @@ export interface Agent {
createdAt: string; createdAt: string;
} }
export interface MyAgent {
id: string;
name: string;
state: string;
enabled: boolean;
hasExtension: boolean;
}
export const AGENT_STATE_LABELS: Record<string, string> = { export const AGENT_STATE_LABELS: Record<string, string> = {
OFFLINE: "Offline", OFFLINE: "Offline",
LOGGED_IN: "Logado", LOGGED_IN: "Logado",

View File

@@ -151,3 +151,29 @@ campo real é `CC-Agent-State`. Os `CC-Action` reais observados no teste
`CC-Cause`/`CC-Cancel-Reason` e timestamps de entrada/saída — abandono vs. `CC-Cause`/`CC-Cancel-Reason` e timestamps de entrada/saída — abandono vs.
atendida), `members-count` (contagem ao vivo de chamadas esperando por atendida), `members-count` (contagem ao vivo de chamadas esperando por
fila). Ver `packages/telephony/src/normalize-event.ts`. fila). Ver `packages/telephony/src/normalize-event.ts`.
## Widget de status na topbar (PHASE 62)
Achado real reportado pelo usuário: "não achei como deixar o agente
online, tem algum perfil ou tipo de usuário específico?" — não era
permissão nenhuma: `POST /agents/me/login|pause|resume|logout`
(`apps/api/src/agents/agents-me.controller.ts`) sempre funcionaram sem
exigir permissão nenhuma (só `JwtAuthGuard`, resolvem "meu agente" via
`userId` do próprio token — nunca aceitam um `agentId` do client). O
problema era que NENHUMA tela chamava esses endpoints — só existia o CRUD
admin de Agentes (criar o vínculo usuário+ramal em Call Center >
Agentes).
Adicionado `GET /agents/me` (404 = usuário sem Agent neste tenant, não um
erro) e `GET /agents/me/pause-reasons` (motivos de pausa sem exigir
`agents.view`, que listaria TODOS os agentes do tenant — permissão que o
role "agent" nunca teve e não devia ganhar só pra isso). `AgentStatusWidget`
na topbar (visível em qualquer página do app do tenant, `apps/app/layout.tsx`
busca `/agents/me` uma vez por request) só aparece pra quem tem Agent
vinculado: Offline → "Entrar" → Disponível → "Pausar" (escolhe motivo) →
Em pausa → "Retomar"/"Sair".
Testado ponta a ponta com Playwright, logado como um agente de teste
real (usuário novo, role "agent", Agent vinculado a um ramal real): o
fluxo completo (entrar/pausar/retomar/sair) funciona pela tela, sem
nenhuma chamada de API manual.