feat(rotas-entrada): dropdown de destino real (ramal/IVR/fila/grupo)

Pedido do usuário: "em vez de o campo de destino ser aberto tem que ter
um dropdown listando todos os ramais do tennant e tb todas ivr e filas
e grupos de ramais do tennant" — até aqui o destino era texto livre.

Construir o dropdown revelou que fila e grupo NUNCA tinham sido
implementados de verdade como destino possível — só ramal/IVR
funcionavam. Oferecer as duas opções sem o mecanismo por trás seria
mostrar um dropdown mentiroso, então:

InboundRoute.destinationType (novo enum EXTENSION/IVR/QUEUE/CALL_GROUP)
decide como buildInboundRouteXml interpreta o destino:
- EXTENSION/IVR: inalterado, o mesmo transfer já testado (PHASE 56/58).
- QUEUE: destinationNumber guarda o Queue.id; a resolução de entrada
  emite `answer` + `callcenter data="<queueId>@<domain>"` direto, sem
  tocar no dialplan "default". `callcenter` entrou no allowlist de
  applications com o mesmo risco zero de `pickup`.
- CALL_GROUP: destinationNumber guarda o Extension.callGroup; a
  resolução consulta AGORA (nunca um snapshot salvo) todos os ramais
  com esse callGroup e emite um `bridge` multi-leg — toca todos ao
  mesmo tempo, quem atender primeiro cancela os outros. Trocar quem
  está no grupo depois de criar a rota já vale na próxima chamada.

Testado ponta a ponta com chamadas reais nos 2 mecanismos novos: fila —
softphone externo discou o DID, show channels confirmou a chamada
dentro da application callcenter com o nome certo da fila; grupo — 2
ramais reais no mesmo callGroup, a chamada tocou nos DOIS ao mesmo
tempo (mesmo call_uuid, ambas RINGING), atender em um cancelou o outro
automaticamente — ring group de verdade.

Tela: "Tipo de destino" + um segundo dropdown com as opções reais do
tenant pra cada tipo (ramais, menus de IVR, filas, ou os valores
distintos de callGroup já usados em algum ramal). Testado com
Playwright: os 4 tipos aparecem, e trocar o tipo atualiza as opções do
segundo dropdown corretamente.

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:55 -03:00
parent 1b05d93c48
commit 738bfa4f36
10 changed files with 342 additions and 58 deletions

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { InboundRoute } from "@/lib/callcenter-types";
import type { InboundRoute, InboundRouteDestinationType } from "@/lib/callcenter-types";
function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
@@ -22,6 +22,7 @@ function extractErrorMessage(err: unknown): string {
export interface CreateInboundRouteInput {
didNumber: string;
description?: string;
destinationType: InboundRouteDestinationType;
destinationContext?: string;
destinationNumber: string;
}

View File

@@ -1,10 +1,16 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { InboundRoute } from "@/lib/callcenter-types";
import type { InboundRoute, IvrMenu, Queue } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import { RotasEntradaView } from "./rotas-entrada-view";
export default async function RotasEntradaPage() {
const session = await requireSession();
const routes = await apiFetch<InboundRoute[]>("/inbound-routes", session.accessToken);
return <RotasEntradaView routes={routes} />;
const [routes, extensions, ivrMenus, queues] = await Promise.all([
apiFetch<InboundRoute[]>("/inbound-routes", session.accessToken),
apiFetch<Extension[]>("/extensions", session.accessToken),
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken),
]);
return <RotasEntradaView routes={routes} extensions={extensions} ivrMenus={ivrMenus} queues={queues} />;
}

View File

@@ -1,29 +1,83 @@
"use client";
import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { PhoneIncoming, 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 { 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 { formatDate } from "@/lib/format";
import type { InboundRoute } from "@/lib/callcenter-types";
import {
INBOUND_ROUTE_DESTINATION_TYPES,
INBOUND_ROUTE_DESTINATION_TYPE_LABELS,
IVR_ENTRY_DESTINATION,
type InboundRoute,
type InboundRouteDestinationType,
type IvrMenu,
type Queue,
} from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import { createInboundRoute, deleteInboundRoute } from "./actions";
export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
function destinationLabel(
route: InboundRoute,
extensionsByNumber: Record<string, Extension>,
ivrMenusByContext: Record<string, IvrMenu>,
queuesById: Record<string, Queue>,
): string {
switch (route.destinationType) {
case "EXTENSION": {
const ext = extensionsByNumber[route.destinationNumber];
return ext ? `Ramal ${ext.number}${ext.name}` : `Ramal ${route.destinationNumber}`;
}
case "IVR": {
const menu = ivrMenusByContext[route.destinationContext];
return menu ? `IVR: ${menu.name}` : `IVR (${route.destinationContext})`;
}
case "QUEUE": {
const queue = queuesById[route.destinationNumber];
return queue ? `Fila: ${queue.name}` : `Fila (${route.destinationNumber})`;
}
case "CALL_GROUP":
return `Grupo: ${route.destinationNumber}`;
default:
return route.destinationNumber;
}
}
export function RotasEntradaView({
routes,
extensions,
ivrMenus,
queues,
}: {
routes: InboundRoute[];
extensions: Extension[];
ivrMenus: IvrMenu[];
queues: Queue[];
}) {
const [showForm, setShowForm] = useState(false);
const extensionsByNumber = useMemo(() => Object.fromEntries(extensions.map((e) => [e.number, e])), [extensions]);
const ivrMenusByContext = useMemo(() => Object.fromEntries(ivrMenus.map((m) => [m.context, m])), [ivrMenus]);
const queuesById = useMemo(() => Object.fromEntries(queues.map((q) => [q.id, q])), [queues]);
const callGroups = useMemo(
() => Array.from(new Set(extensions.map((e) => e.callGroup).filter((g): g is string => !!g))).sort(),
[extensions],
);
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">Rotas de entrada</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Cada número (DID) que um tronco recebe vira uma rota própria, apontando pra um ramal, fila ou IVR um
tronco pode carregar vários números com destinos diferentes. O número (DID) é único entre todos os
tenants: é a única forma de saber de quem é uma chamada de entrada antes de identificar o tenant.
Cada número (DID) que um tronco recebe vira uma rota própria, apontando pra um ramal, IVR, fila ou grupo
de ramais um tronco pode carregar vários números com destinos diferentes. O número (DID) é único entre
todos os tenants: é a única forma de saber de quem é uma chamada de entrada antes de identificar o
tenant.
</p>
</div>
<Button type="button" onClick={() => setShowForm((s) => !s)}>
@@ -32,7 +86,9 @@ export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
</Button>
</div>
{showForm && <NewInboundRouteForm onDone={() => setShowForm(false)} />}
{showForm && (
<NewInboundRouteForm extensions={extensions} ivrMenus={ivrMenus} queues={queues} callGroups={callGroups} onDone={() => setShowForm(false)} />
)}
<Panel>
<PanelHeader title="Rotas cadastradas" description={`${routes.length} rota(s) neste tenant`} />
@@ -62,9 +118,7 @@ export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
</span>
</TD>
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
<TD className="font-mono text-muted-foreground">
{r.destinationNumber} <span className="text-xs">({r.destinationContext})</span>
</TD>
<TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
<TD>
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
</TD>
@@ -82,25 +136,62 @@ export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
);
}
function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
function NewInboundRouteForm({
extensions,
ivrMenus,
queues,
callGroups,
onDone,
}: {
extensions: Extension[];
ivrMenus: IvrMenu[];
queues: Queue[];
callGroups: string[];
onDone: () => void;
}) {
const [didNumber, setDidNumber] = useState("");
const [description, setDescription] = useState("");
const [destinationNumber, setDestinationNumber] = useState("");
const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>("EXTENSION");
const [destinationValue, setDestinationValue] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function onTypeChange(next: InboundRouteDestinationType) {
setDestinationType(next);
setDestinationValue("");
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!didNumber.trim() || !destinationNumber.trim()) {
setError("DID e destino são obrigatórios.");
if (!didNumber.trim()) {
setError("Informe o número (DID).");
return;
}
if (!destinationValue) {
setError("Selecione um destino.");
return;
}
let destinationNumber = destinationValue;
let destinationContext: string | undefined;
if (destinationType === "IVR") {
const menu = ivrMenus.find((m) => m.id === destinationValue);
if (!menu) {
setError("Menu de IVR não encontrado.");
return;
}
destinationNumber = IVR_ENTRY_DESTINATION;
destinationContext = menu.context;
}
startTransition(async () => {
const result = await createInboundRoute({
didNumber: didNumber.trim(),
description: description.trim() || undefined,
destinationNumber: destinationNumber.trim(),
destinationType,
destinationNumber,
destinationContext,
});
if (!result.ok) {
setError(result.error);
@@ -113,7 +204,7 @@ function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
return (
<Panel className="p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FieldLabel htmlFor="ir-did">Número (DID)</FieldLabel>
<Input
@@ -124,16 +215,6 @@ function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor="ir-destination">Ramal de destino</FieldLabel>
<Input
id="ir-destination"
value={destinationNumber}
onChange={(e) => setDestinationNumber(e.target.value)}
placeholder="Ex.: 1001"
disabled={pending}
/>
</div>
<div>
<FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel>
<Input
@@ -145,6 +226,67 @@ function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FieldLabel htmlFor="ir-type">Tipo de destino</FieldLabel>
<Select
id="ir-type"
value={destinationType}
onChange={(e) => onTypeChange(e.target.value as InboundRouteDestinationType)}
disabled={pending}
>
{INBOUND_ROUTE_DESTINATION_TYPES.map((t) => (
<option key={t} value={t}>
{INBOUND_ROUTE_DESTINATION_TYPE_LABELS[t]}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="ir-destination">Destino</FieldLabel>
<Select id="ir-destination" value={destinationValue} onChange={(e) => setDestinationValue(e.target.value)} disabled={pending}>
<option value="">Selecione</option>
{destinationType === "EXTENSION" &&
extensions.map((ext) => (
<option key={ext.id} value={ext.number}>
{ext.number} {ext.name}
</option>
))}
{destinationType === "IVR" &&
ivrMenus.map((menu) => (
<option key={menu.id} value={menu.id}>
{menu.name}
</option>
))}
{destinationType === "QUEUE" &&
queues.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
{destinationType === "CALL_GROUP" &&
callGroups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
{destinationType === "EXTENSION" && extensions.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Nenhum ramal cadastrado ainda.</p>
)}
{destinationType === "IVR" && ivrMenus.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Nenhum menu de IVR cadastrado ainda (Telefonia &gt; IVR).</p>
)}
{destinationType === "QUEUE" && queues.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Nenhuma fila cadastrada ainda (Call Center &gt; Filas).</p>
)}
{destinationType === "CALL_GROUP" && callGroups.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">
Nenhum ramal tem grupo de captura definido ainda (Telefonia &gt; Ramais).
</p>
)}
</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}