feat: add outbound routes (Issabel-style dial pattern masking)
Nova aba 'Dialplan -> Rotas de Saída': abstração amigável sobre o dialplan bruto, no mesmo espírito das Outbound Routes do Issabel/FreePBX. O usuário informa (prepend) + prefix | match pattern (sintaxe de padrão do Asterisk: X/Z/N/faixas/coringas, sem o '_' inicial) e escolhe o tronco, sem escrever exten=>/Dial() à mão. - packages/database: model OutboundRoute + migration - apps/api/src/outbound-routes: gerador determinístico (contexto b2bcall-outbound-routes, incluído em b2bcall-agents — contexto padrão de Extension.context), service/controller CRUD reaproveitando as permissões dialplans.*, 6 testes unitários - apps/api/src/dialplan/dialplan.service.ts: publish() agora gera e verifica também o contexto das rotas, no mesmo pipeline de versionamento/rollback automático das entradas de dialplan brutas - apps/frontend: aba com formulário (prepend/prefix/padrão/tronco) e preview ao vivo da máscara resultante O prefixo é sempre removido do número antes de discar e substituído pelo prepend — o tronco nunca vê o prefixo digitado pelo agente, só o número já mascarado. Não usado pelo discador preditivo (campanhas já sabem o tronco via Campaign.trunkId diretamente). Build/lint/testes (api+frontend) verificados; teste end-to-end contra o Asterisk real ficou pendente porque a senha do super_admin foi trocada durante a sessão (acesso legítimo do usuário) — validação via UI delegada ao usuário.
This commit is contained in:
@@ -18,10 +18,19 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { dialplanService, type DialplanEntryInput } from '@/services/dialplan';
|
||||
import type { DialplanEntry, DialplanVersion } from '@/types';
|
||||
import { outboundRoutesService, type OutboundRouteInput } from '@/services/outbound-routes';
|
||||
import { trunksService } from '@/services/trunks';
|
||||
import type { DialplanEntry, DialplanVersion, OutboundRoute } from '@/types';
|
||||
import { errorMessage } from '@/lib/error-message';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
@@ -270,6 +279,316 @@ function EntriesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ROUTE_FORM: OutboundRouteInput = {
|
||||
name: '',
|
||||
description: '',
|
||||
prefix: '',
|
||||
matchPattern: '',
|
||||
prepend: '',
|
||||
trunkId: '',
|
||||
order: 0,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Mesma lógica de apps/api/src/outbound-routes/outbound-route-generator.ts —
|
||||
// só para preview, quem manda de verdade é o backend ao publicar.
|
||||
function previewRoute(form: OutboundRouteInput, trunkName: string | undefined) {
|
||||
const pattern = `_${form.prefix ?? ''}${form.matchPattern || '...'}`;
|
||||
const stripLen = (form.prefix ?? '').length;
|
||||
const dialed = stripLen > 0 ? `\${EXTEN:${stripLen}}` : '${EXTEN}';
|
||||
const sent = `${form.prepend ?? ''}${dialed}`;
|
||||
return `${pattern} → Dial(PJSIP/${sent}@${trunkName || '<tronco>'})`;
|
||||
}
|
||||
|
||||
function RouteFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
route,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
route: OutboundRoute | null;
|
||||
}) {
|
||||
const isEdit = Boolean(route);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [form, setForm] = React.useState<OutboundRouteInput>(EMPTY_ROUTE_FORM);
|
||||
|
||||
const { data: trunks } = useQuery({
|
||||
queryKey: ['trunks'],
|
||||
queryFn: trunksService.list,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setForm(
|
||||
route
|
||||
? {
|
||||
name: route.name,
|
||||
description: route.description ?? '',
|
||||
prefix: route.prefix,
|
||||
matchPattern: route.matchPattern,
|
||||
prepend: route.prepend ?? '',
|
||||
trunkId: route.trunkId,
|
||||
order: route.order,
|
||||
enabled: route.enabled,
|
||||
}
|
||||
: EMPTY_ROUTE_FORM,
|
||||
);
|
||||
}
|
||||
}, [open, route]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
isEdit && route
|
||||
? outboundRoutesService.update(route.id, form)
|
||||
: outboundRoutesService.create(form),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['outbound-routes'] });
|
||||
toast({ title: 'Rota salva', variant: 'success' });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const selectedTrunkName = trunks?.find((t) => t.id === form.trunkId)?.name;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Editar rota de saída' : 'Nova rota de saída'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="route-name">Nome</Label>
|
||||
<Input
|
||||
id="route-name"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="route-description">Descrição</Label>
|
||||
<Input
|
||||
id="route-description"
|
||||
value={form.description ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="route-prepend">Prepend</Label>
|
||||
<Input
|
||||
id="route-prepend"
|
||||
placeholder="55"
|
||||
value={form.prepend ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, prepend: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="route-prefix">Prefixo</Label>
|
||||
<Input
|
||||
id="route-prefix"
|
||||
placeholder="0"
|
||||
value={form.prefix ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, prefix: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="route-pattern">Padrão (CalledID)</Label>
|
||||
<Input
|
||||
id="route-pattern"
|
||||
required
|
||||
placeholder="NXXXXXXXXX"
|
||||
value={form.matchPattern}
|
||||
onChange={(e) => setForm((f) => ({ ...f, matchPattern: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground rounded-md border border-dashed px-3 py-2 font-mono text-xs">
|
||||
{previewRoute(form, selectedTrunkName)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
O prefixo é sempre removido do número antes de discar e substituído pelo
|
||||
prepend — o Asterisk nunca vê o prefixo, só o tronco recebe o número já
|
||||
mascarado. Padrão aceita dígitos, X (0-9), Z (1-9), N (2-9), faixas como
|
||||
[1-5], . (um ou mais dígitos) e ! (zero ou mais).
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="route-trunk">Tronco</Label>
|
||||
<Select value={form.trunkId} onValueChange={(v) => setForm((f) => ({ ...f, trunkId: v }))}>
|
||||
<SelectTrigger id="route-trunk">
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{trunks?.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="route-order">Ordem</Label>
|
||||
<Input
|
||||
id="route-order"
|
||||
type="number"
|
||||
value={form.order ?? 0}
|
||||
onChange={(e) => setForm((f) => ({ ...f, order: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" loading={mutation.isPending} disabled={!form.trunkId}>
|
||||
Salvar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutesTab() {
|
||||
const { can } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<OutboundRoute | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['outbound-routes'],
|
||||
queryFn: outboundRoutesService.list,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => outboundRoutesService.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['outbound-routes'] });
|
||||
toast({ title: 'Rota removida', variant: 'success' });
|
||||
},
|
||||
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: () => dialplanService.publish(),
|
||||
onSuccess: (version) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dialplan-versions'] });
|
||||
toast({
|
||||
title: version.status === 'APPLIED' ? 'Dialplan publicado' : 'Falha ao publicar',
|
||||
description: version.reloadResult ?? undefined,
|
||||
variant: version.status === 'APPLIED' ? 'success' : 'destructive',
|
||||
});
|
||||
},
|
||||
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const columns: DataTableColumn<OutboundRoute>[] = [
|
||||
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||
{
|
||||
key: 'pattern',
|
||||
header: 'Máscara',
|
||||
render: (r) => (
|
||||
<span className="font-mono text-xs">
|
||||
({r.prepend || '—'}) + {r.prefix || '—'} | {r.matchPattern}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'trunk', header: 'Tronco', render: (r) => r.trunk.name },
|
||||
{ key: 'order', header: 'Ordem', render: (r) => r.order },
|
||||
{
|
||||
key: 'enabled',
|
||||
header: 'Status',
|
||||
render: (r) => (
|
||||
<Badge variant={r.enabled ? 'success' : 'secondary'}>
|
||||
{r.enabled ? 'Ativa' : 'Inativa'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'text-right',
|
||||
render: (r) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
{can('dialplans.update') && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{can('dialplans.delete') && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm('Remover esta rota de saída?')) remove.mutate(r.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Rotas de saída: (prepend) + prefixo | padrão de casamento, no mesmo estilo do
|
||||
Issabel/FreePBX — sem escrever dialplan à mão. O prefixo é sempre removido do
|
||||
número antes de mandar ao tronco escolhido.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
{can('dialplans.update') && (
|
||||
<Button variant="outline" loading={publish.isPending} onClick={() => publish.mutate()}>
|
||||
<UploadCloud /> Publicar
|
||||
</Button>
|
||||
)}
|
||||
{can('dialplans.create') && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus /> Nova rota
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={() => refetch()}
|
||||
rowKey={(r) => r.id}
|
||||
emptyMessage="Nenhuma rota de saída cadastrada."
|
||||
/>
|
||||
<RouteFormDialog open={dialogOpen} onOpenChange={setDialogOpen} route={editing} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionsTab() {
|
||||
const { can } = useAuth();
|
||||
const { toast } = useToast();
|
||||
@@ -335,11 +654,15 @@ function DialplanContent() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Dialplan" description="Plano de discagem estruturado e versionado" />
|
||||
<Tabs defaultValue="entries">
|
||||
<Tabs defaultValue="routes">
|
||||
<TabsList>
|
||||
<TabsTrigger value="routes">Rotas de Saída</TabsTrigger>
|
||||
<TabsTrigger value="entries">Entradas</TabsTrigger>
|
||||
<TabsTrigger value="versions">Versões</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="routes">
|
||||
<RoutesTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="entries">
|
||||
<EntriesTab />
|
||||
</TabsContent>
|
||||
|
||||
21
apps/frontend/src/services/outbound-routes.ts
Normal file
21
apps/frontend/src/services/outbound-routes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { OutboundRoute } from '@/types';
|
||||
|
||||
export interface OutboundRouteInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
prefix?: string;
|
||||
matchPattern: string;
|
||||
prepend?: string;
|
||||
trunkId: string;
|
||||
order?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const outboundRoutesService = {
|
||||
list: () => api.get<OutboundRoute[]>('/outbound-routes'),
|
||||
create: (input: OutboundRouteInput) => api.post<OutboundRoute>('/outbound-routes', input),
|
||||
update: (id: string, input: Partial<OutboundRouteInput>) =>
|
||||
api.patch<OutboundRoute>(`/outbound-routes/${id}`, input),
|
||||
remove: (id: string) => api.delete<void>(`/outbound-routes/${id}`),
|
||||
};
|
||||
@@ -105,6 +105,21 @@ export interface DialplanVersion {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OutboundRoute {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
prefix: string;
|
||||
matchPattern: string;
|
||||
prepend: string | null;
|
||||
trunkId: string;
|
||||
trunk: { id: string; name: string };
|
||||
order: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type QueueStrategy =
|
||||
| 'ringall'
|
||||
| 'leastrecent'
|
||||
|
||||
Reference in New Issue
Block a user