diff --git a/apps/api/src/agents/agents-me.controller.ts b/apps/api/src/agents/agents-me.controller.ts index c06e310..fd55eb9 100644 --- a/apps/api/src/agents/agents-me.controller.ts +++ b/apps/api/src/agents/agents-me.controller.ts @@ -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") diff --git a/apps/frontend/src/app/app/layout.tsx b/apps/frontend/src/app/app/layout.tsx index 4c9a6de..adae9b8 100644 --- a/apps/frontend/src/app/app/layout.tsx +++ b/apps/frontend/src/app/app/layout.tsx @@ -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("/agents/me", session.accessToken); + pauseReasons = await apiFetch("/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 (
- +
{children}
diff --git a/apps/frontend/src/components/shell/topbar.tsx b/apps/frontend/src/components/shell/topbar.tsx index 07d39d4..6862d66 100644 --- a/apps/frontend/src/components/shell/topbar.tsx +++ b/apps/frontend/src/components/shell/topbar.tsx @@ -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[
+ {rightExtra} + {rightExtra &&
} { + 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 { + return callAgentMe("/login"); +} +export async function agentLogout(): Promise { + return callAgentMe("/logout"); +} +export async function agentResume(): Promise { + return callAgentMe("/resume"); +} +export async function agentPause(pauseReasonId: string): Promise { + return callAgentMe("/pause", { pauseReasonId }); +} diff --git a/apps/frontend/src/components/tenant-shell/agent-status-widget.tsx b/apps/frontend/src/components/tenant-shell/agent-status-widget.tsx new file mode 100644 index 0000000..1dafbb6 --- /dev/null +++ b/apps/frontend/src/components/tenant-shell/agent-status-widget.tsx @@ -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(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 ( +
+ + {AGENT_STATE_LABELS[agent.state] ?? agent.state} + + {isOffline && !agent.hasExtension && ( + Sem ramal configurado + )} + + {isOffline && agent.hasExtension && ( + + )} + + {isAvailable && ( + <> + + + + )} + + {isPaused && ( + <> + + + + )} + + {showPausePicker && ( +
+ + +
+ )} + + {error && {error}} +
+ ); +} diff --git a/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx b/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx index 9d05afe..2937d7b 100644 --- a/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx +++ b/apps/frontend/src/components/tenant-shell/tenant-topbar.tsx @@ -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 ; + return ( + } + /> + ); } diff --git a/apps/frontend/src/lib/callcenter-types.ts b/apps/frontend/src/lib/callcenter-types.ts index af92203..fd76606 100644 --- a/apps/frontend/src/lib/callcenter-types.ts +++ b/apps/frontend/src/lib/callcenter-types.ts @@ -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 = { + 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 = { OFFLINE: "Offline", LOGGED_IN: "Logado", diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6863ead..36424ef 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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. atendida), `members-count` (contagem ao vivo de chamadas esperando por 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.