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,
|
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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 { 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} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user