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:
@@ -3,6 +3,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
@@ -35,6 +36,41 @@ async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userI
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller("agents/me")
|
||||
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,
|
||||
* cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */
|
||||
@Post("login")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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 { TenantTopbar } from "@/components/tenant-shell/tenant-topbar";
|
||||
import type { MyAgent, PauseReason } from "@/lib/callcenter-types";
|
||||
|
||||
interface Me {
|
||||
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.
|
||||
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 (
|
||||
<div className="flex h-dvh overflow-hidden bg-background">
|
||||
<TenantSidebar railLabel={me.tenant.name} permissionKeys={me.permissionKeys} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,17 @@ import { ThemeToggle } from "../platform-shell/theme-toggle";
|
||||
import { MobileNavDrawer } from "./mobile-nav-drawer";
|
||||
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 pathname = usePathname();
|
||||
const { title, description } = getPageMeta(items, pathname, fallbackTitle);
|
||||
@@ -28,6 +38,8 @@ export function Topbar({ items, fallbackTitle, userLabel }: { items: NavSection[
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
href="https://www.handix.com.br"
|
||||
target="_blank"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,30 @@
|
||||
|
||||
import { Topbar } from "@/components/shell/topbar";
|
||||
import { filterNavByPermissions } from "@/components/shell/nav-types";
|
||||
import type { MyAgent, PauseReason } from "@/lib/callcenter-types";
|
||||
import { TENANT_NAV } from "./nav-data";
|
||||
import { AgentStatusWidget } from "./agent-status-widget";
|
||||
|
||||
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
|
||||
export function TenantTopbar({
|
||||
fallbackTitle,
|
||||
userLabel,
|
||||
permissionKeys,
|
||||
myAgent,
|
||||
pauseReasons,
|
||||
}: {
|
||||
fallbackTitle: string;
|
||||
userLabel: 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,21 @@ export interface Trunk {
|
||||
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 {
|
||||
id: string;
|
||||
didNumber: string;
|
||||
description: string | null;
|
||||
destinationType: InboundRouteDestinationType;
|
||||
destinationContext: string;
|
||||
destinationNumber: string;
|
||||
enabled: boolean;
|
||||
@@ -123,6 +134,14 @@ export interface Agent {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MyAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
enabled: boolean;
|
||||
hasExtension: boolean;
|
||||
}
|
||||
|
||||
export const AGENT_STATE_LABELS: Record<string, string> = {
|
||||
OFFLINE: "Offline",
|
||||
LOGGED_IN: "Logado",
|
||||
|
||||
Reference in New Issue
Block a user