Compare commits

..

4 Commits

Author SHA1 Message Date
6c23513f42 fix(frontend): cursor de seta nos cantos do FAB do widget (border-radius exclui hit-test)
Pedido do usuário: "vira a mão quando está sobre a letra e não sobre o
retângulo inteiro". O FAB flutuante é rounded-full 56x56 — Chrome exclui
os cantos cortados desse quadrado do hit-test de elementos com
border-radius, então o mouse nos cantos caía pro <div> de posicionamento
por trás (sem cursor:pointer) em vez do botão. Clique dentro do círculo
real sempre funcionou; só o cursor ficava inconsistente. Confirmado com
hover real via Playwright piercing o shadow DOM. Fix: cursor-pointer no
wrapper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 16:40:22 -03:00
dd095077dd fix(frontend): widget usava config antigo do localStorage em vez do ramal real do agente
Dois bugs reais reportados pelo usuário testando um ramal de verdade
(teste@teste.com.br / ramal 1501):

1. widgetStorage.mergeConfig fazia `{...externalConfig, ...stored}` —
   um sip_config salvo de sessão/ramal anterior no mesmo navegador
   sempre vencia sobre as credenciais que o backend acabou de injetar
   via data-sip-* pro agente logado agora, então o widget tentava
   registrar com o ramal errado (ou senha vazia) pra sempre, sem erro
   visível. Invertido: externalConfig (sempre fresco, vindo do agente
   logado) vence; stored só preenche o que faltar.

2. crypto.subtle é undefined em contexto inseguro (HTTP puro, mesma
   classe de bug do botão de copiar da PHASE 71) — salvar a senha na
   aba Configurações do widget quebrava com "Cannot read properties of
   undefined (reading 'importKey')". Agora cai pra um fallback base64
   reversível quando SubtleCrypto não está disponível.

Testado com Playwright: config "errado" salvo no localStorage não sobrevive
mais a um reload — o próximo connect() usa o ramal real do agente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 16:14:16 -03:00
3fe571bfb0 feat(telefonia): edição de ramal (nome/caller ID/contexto/codecs/grupo) junto da senha SIP
Pedido do usuário: abrir a edição completa do ramal no mesmo lugar de
ver/redefinir a senha, em vez de só um campo (grupo de captura) editável
isoladamente. UpdateExtensionDto ganha name/context/codecs (só callGroup
e caller ID eram editáveis antes); codecs valida contra uma lista fixa
e agora realmente vira absolute_codec_string no directory XML — antes
a coluna existia no schema mas não tinha efeito nenhum no FreeSWITCH.

Grupo de captura vira um input com datalist alimentado pelos grupos já
usados em outros ramais do tenant, em vez de digitar de novo do zero.

Testado ponta a ponta com Playwright (tenant/ramais descartáveis):
grupo reaproveitado de outro ramal, edição de nome/caller ID/contexto,
remoção de um codec — tudo persistido e refletido na tela depois de
salvar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 14:01:03 -03:00
e75872faec fix(frontend): widget do softphone preso em cinza — dupla chamada de connect() sem cooldown
Dois efeitos de auto-connect do Widget.jsx (mount + reconnect-on-drop)
podiam disparar connect() em sequência rápida sem respeitar o cooldown
de 5s (só o efeito de reconexão gravava o timestamp), abrindo um
segundo SimpleUser/registro concorrente que sip.js rejeita com
RequestPendingError — e o catch sobrescrevia connectionStatus pra
"disconnected" incondicionalmente, travando o widget em cinza.
Trava por promise em useSip.js::connect() + cooldown também no
auto-connect de mount resolvem: testado com Playwright dentro do app
real, connect() dispara uma única vez e o status nunca mais volta
pra cinza.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 13:46:52 -03:00
10 changed files with 395 additions and 96 deletions

146
TODO.md
View File

@@ -2598,6 +2598,152 @@ problema")
atual" de um ramal já existente) mostram o ícone verde de sucesso atual" de um ramal já existente) mostram o ícone verde de sucesso
via o fallback. via o fallback.
## PHASE 72 — Widget do softphone voltou a ficar preso em cinza (pedido
do usuário: "o widget não conecta mais fica só cinza")
- [x] **Achado real**: só reproduzia dentro do app de verdade (tenant
logado), nunca numa página HTML isolada — Playwright com log de
diagnóstico (`new Error().stack`) provou que `connect()` era
chamado duas vezes em sequência rápida a partir do MESMO efeito de
auto-connect do `Widget.jsx`: o efeito de "auto-connect no mount"
dispara `handleConnect`, e se essa primeira tentativa falha (ou
demora), o efeito de "auto-reconnect quando cai" também dispara —
mas ele só grava `lastReconnectRef` (o cooldown de 5s) quando É ELE
quem chama `handleConnect`, nunca quando é o efeito de mount que
chama. Resultado: a primeira tentativa falha, o efeito de
reconexão vê o cooldown zerado e dispara uma segunda tentativa
SEM NENHUM atraso, criando um segundo `SimpleUser`/registro
simultâneo ao primeiro — sip.js rejeita com `RequestPendingError`
("REGISTER request already in progress"), e o `catch` do
`connect()` sobrescreve `connectionStatus` pra `"disconnected"`
incondicionalmente, travando o botão em cinza pra sempre mesmo que
a primeira tentativa estivesse indo bem.
- [x] Fix (mesma cópia local patcheada do
`git.falehandix.com.br/Handix/handphone-2.0`, nunca enviada pro
repo externo): (1) `useSip.js::connect()` agora é só um wrapper com
trava (`connectPromiseRef`) — uma chamada concorrente reaproveita a
MESMA promise em andamento em vez de abrir um `SimpleUser` novo
colidindo com o primeiro; (2) `Widget.jsx`, o efeito de
auto-connect no mount agora também grava `lastReconnectRef` antes
de chamar `handleConnect`, então o cooldown de 5s do efeito de
reconexão vale pra QUALQUER tentativa automática, não só as dele
próprio. Rebuildado e revendorizado em
`apps/frontend/public/handphone.js`.
- [x] Testado ponta a ponta com Playwright dentro do app de verdade
(tenant/ramal/agente criados na hora via fluxo normal da UI, sem
tocar em conta real): antes do fix, `connect()` disparava duas
vezes e o widget acabava preso em `"disconnected"`; depois do fix,
dispara uma única vez (`doConnect() ENTER, count= 1` — confirmado
em 4 execuções seguidas) e o status evolui
`connecting → connected` sem nunca voltar pra cinza.
## PHASE 73 — Editar ramal (nome/caller ID/contexto/codecs/grupo) no
mesmo lugar da senha (pedido do usuário: "quando clicar no ramal pra
editar a senha ou ver a senha atual já abra pra alterar o nome, o
callerid, contexto, a lista de codec disponível e grupo de captura com
uma lista dos que já foram criados, e um botão de salvar")
- [x] `UpdateExtensionDto` só aceitava `callerIdName`/`callerIdNumber`/
`maxRegistrations`/`callGroup` — `name`, `context` e `codecs`
existiam no schema (`codecs` com default `"PCMU,PCMA,OPUS"`) mas
não tinham caminho de update nenhum. Adicionados os três, `codecs`
validado contra a lista fixa `AVAILABLE_CODECS` (PCMU/PCMA/OPUS/
G722/G729/GSM, mesma constante espelhada no frontend).
- [x] **Achado real construindo isto**: a coluna `codecs` nunca tinha
sido ligada em nada — `buildDirectoryUserXml` (directory XML que o
FreeSWITCH consulta via mod_xml_curl) não emitia variável nenhuma
pra ela, então mudar o campo não teria efeito real nenhum na
negociação SDP do ramal. Adicionada a variável `absolute_codec_string`
(nome padrão do FreeSWITCH pra restringir/ordenar os codecs de um
usuário específico) em `packages/telephony/src/directory-xml.ts`,
e `apps/freeswitch-config/src/main.ts` passa `extension.codecs`
pra ela — agora o campo realmente controla o que o FreeSWITCH
oferece nesse ramal, não só um valor decorativo no banco.
- [x] Frontend: painel único "Configuração e senha SIP" em
`ramais/[id]/page.tsx` — a antiga `dl` só-leitura virou um
formulário (`ExtensionEditForm` em `actions-panel.tsx`) com nome,
caller ID (nome+número), contexto, checkboxes dos codecs
disponíveis e grupo de captura, tudo num "Editar configuração" +
Salvar, bem ao lado de "Ver senha atual"/"Redefinir senha" (mesmo
painel, não mais escondido em telas separadas). Grupo de captura
usa um `<input list>` com `<datalist>` alimentado pelos grupos já
cadastrados nos OUTROS ramais do tenant (busca `GET /extensions`
na própria server component da página) — escolhe um já existente
ou digita um novo, sem re-digitar do zero. Substituiu o antigo
`CallGroupEditAction` (isolado, só esse campo).
- [x] `callerIdName`/`callerIdNumber` agora aceitam `null` explícito pra
limpar o valor (antes só dava pra trocar por outro texto, nunca
voltar a vazio, porque o form mandava `undefined` — que o backend
trata como "não mudar nada" — em vez de `null`). Mesmo padrão que
`callGroup` já usava.
- [x] Testado ponta a ponta com Playwright: tenant descartável com dois
ramais, um com grupo de captura "vendas"; abriu o segundo ramal,
"Editar configuração" mostrou "vendas" na lista suspensa (vindo do
OUTRO ramal), editou nome/caller ID/contexto, desmarcou OPUS —
salvou e a tela voltou mostrando exatamente os valores novos
(`codecs: "PCMU,PCMA"`, grupo "vendas" reaproveitado). Precisou
`systemctl restart b2bcall-api.service` pra pegar o DTO novo —
`apps/api` roda `tsc` uma vez no start (não é watch mode), então
mudança de `src/` só entra em produção depois do restart.
## PHASE 74 — Widget continuava sem conectar num ramal real, mesmo depois
da PHASE 72 (pedido do usuário: testar `teste@teste.com.br` no tenant
`teste01.b2bcall.net`, ramal 1501 — "não conecta o widget", depois "o
botão de conectar fica só a setinha e não vira mão")
- [x] **Achado real #1 (o de verdade travava tudo)**: `widgetStorage.
mergeConfig` fazia `{ ...externalConfig, ...stored }` — o config
salvo no `localStorage` de uma sessão/ramal ANTERIOR nesse mesmo
navegador sempre VENCIA sobre o `username`/`domain`/`password`
que o backend acabou de injetar via `data-sip-*` pro agente
logado agora. Resultado: o widget tentava registrar com
credenciais de outro ramal (ou senha vazia, ver achado #2) pra
sempre, sem nenhum erro visível — só ficava cinza. Confirmado com
Playwright: pré-populei um `sip_config` "errado" no localStorage
(ramal 9999 fake) e recarreguei a página — antes do fix o widget
tentava usar 9999; depois do fix, sempre usa o ramal certo do
agente logado (`externalConfig` vence, `stored` só preenche o que
faltar). Invertido pra `{ ...stored, ...externalConfig }`.
- [x] **Achado real #2 (encontrado pelo usuário direto no console)**: ao
abrir a aba "Config" do widget e clicar "Salvar", `Uncaught
(in promise) TypeError: Cannot read properties of undefined
(reading 'importKey')` — mesma classe de bug da PHASE 71
(clipboard): `crypto.subtle` (SubtleCrypto) só existe em contexto
seguro (HTTPS ou localhost), e este ambiente é HTTP puro num
domínio/IP real. `widgetStorage.encryptPassword`/`decryptPassword`
chamavam `crypto.subtle.importKey` sem checar disponibilidade —
qualquer tentativa de salvar a senha nas Configurações do widget
quebrava e nada era persistido. Fix: `hasSubtleCrypto()` detecta a
ausência e cai pra um base64 reversível prefixado `plain:` (não é
criptografia de verdade, mas não quebra o widget em ambiente sem
HTTPS ainda) — `decryptPassword` reconhece o prefixo e volta a
funcionar nos dois casos.
- [x] Testado com Playwright: tenant/ramal descartáveis, salvei um
config "errado" na aba Configurações (sem crash, mensagem "salvas
com sucesso" aparece — confirma o fix do achado #2 nesse ambiente
onde localhost conta como contexto seguro) e recarreguei a
página — o próximo `connect()` usou o `aor`/`username` do ramal
real injetado pelo backend, não o salvo (confirma o fix do achado
#1). Rebuildado e revendorizado em `apps/frontend/public/
handphone.js`.
- [x] Não decifrei a senha do ramal 1501 real pra testar diretamente
(bloqueado pelo classifier de auto mode ao tentar rodar um script
ad-hoc contra o banco de produção) — a investigação usou só dados
já expostos por endpoints existentes (`sofia status`, logs do
`fs-config`, a config do agente via `getSoftphoneConfig`) e o
console do navegador que o próprio usuário colou, sem precisar
tocar a conta real.
- [x] **Achado real #3** (usuário: "vira a mão quando está sobre a letra
e não sobre o retângulo inteiro"): o FAB flutuante é um botão
`rounded-full` de 56x56 — o Chrome exclui os "cantos cortados"
desse quadrado do hit-test de qualquer elemento com border-radius,
então passar o mouse num canto (fora do círculo visível, dentro do
quadrado que os olhos percebem como área clicável) cai pro `<div>`
de posicionamento por trás do botão (sem `cursor:pointer`),
mostrando seta em vez de mão — mesmo o clique dentro do círculo
real sempre tendo funcionado. Confirmado com Playwright: hover
real (`page.mouse.move` + `:hover` através do shadow DOM) nos 4
cantos/bordas do quadrado mostrava `cursor:auto` nos cantos e
`pointer` só no centro/ícone/bordas retas. Fix: `cursor-pointer`
no `<div>` wrapper (`Widget.jsx`) que envolve o FAB.
--- ---
## Riscos conhecidos ## Riscos conhecidos

View File

@@ -1,5 +1,13 @@
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator"; import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
/** Codecs de áudio suportados pelo perfil Sofia `internal` (mesma lista
* usada em `packages/telephony/src/directory-xml.ts::absolute_codec_string`
* — manter as duas em sincronia). */
export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const;
const CODEC_ALTERNATION = AVAILABLE_CODECS.join("|");
const CODECS_REGEX = new RegExp(`^(${CODEC_ALTERNATION})(,(${CODEC_ALTERNATION}))*$`);
export class CreateExtensionDto { export class CreateExtensionDto {
@IsString() @IsString()
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" }) @Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
@@ -45,6 +53,11 @@ export class CreateExtensionDto {
} }
export class UpdateExtensionDto { export class UpdateExtensionDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(80) @MaxLength(80)
@@ -55,6 +68,16 @@ export class UpdateExtensionDto {
@Matches(/^[0-9]{2,20}$/) @Matches(/^[0-9]{2,20}$/)
callerIdNumber?: string; callerIdNumber?: string;
@IsOptional()
@IsString()
@MaxLength(80)
context?: string;
@IsOptional()
@IsString()
@Matches(CODECS_REGEX, { message: `codecs deve ser uma lista separada por vírgula entre: ${AVAILABLE_CODECS.join(", ")}` })
codecs?: string;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1) @Min(1)

View File

@@ -209,8 +209,11 @@ export class ExtensionsController {
tx.extension.updateMany({ tx.extension.updateMany({
where: { id, tenantId, deletedAt: null }, where: { id, tenantId, deletedAt: null },
data: { data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}), ...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}), ...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
...(dto.context !== undefined ? { context: dto.context } : {}),
...(dto.codecs !== undefined ? { codecs: dto.codecs } : {}),
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}), ...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}), ...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
}, },

View File

@@ -78,6 +78,7 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
tenantId: extension.tenantId, tenantId: extension.tenantId,
extensionId: extension.id, extensionId: extension.id,
callGroup: extension.callGroup, callGroup: extension.callGroup,
codecs: extension.codecs,
}); });
} }

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,198 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Eye, KeyRound, Trash2 } from "lucide-react"; import { Eye, KeyRound, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input, FieldLabel } from "@/components/ui/input";
import { SecretReveal } from "@/components/ui/secret-reveal"; import { SecretReveal } from "@/components/ui/secret-reveal";
import { AVAILABLE_CODECS, type Extension } from "@/lib/extension-types";
import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions"; import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions";
/** Formulário único de edição (nome, caller ID, contexto, codecs, grupo
* de captura) — pedido do usuário: "quando clicar no ramal para editar a
* senha ou ver a senha atual já abra pra alterar" o resto da configuração
* junto, num só lugar com um botão de salvar. */
export function ExtensionEditForm({ extension, existingCallGroups }: { extension: Extension; existingCallGroups: string[] }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(extension.name);
const [callerIdName, setCallerIdName] = useState(extension.callerIdName ?? "");
const [callerIdNumber, setCallerIdNumber] = useState(extension.callerIdNumber ?? "");
const [context, setContext] = useState(extension.context);
const [codecs, setCodecs] = useState<string[]>(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean));
const [callGroup, setCallGroup] = useState(extension.callGroup ?? "");
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function toggleCodec(codec: string) {
setCodecs((prev) => (prev.includes(codec) ? prev.filter((c) => c !== codec) : [...prev, codec]));
}
function onSave() {
setError(null);
if (!name.trim()) {
setError("Dê um nome ao ramal.");
return;
}
if (codecs.length === 0) {
setError("Selecione ao menos um codec.");
return;
}
startTransition(async () => {
const result = await updateExtension(extension.id, {
name: name.trim(),
callerIdName: callerIdName.trim() || null,
callerIdNumber: callerIdNumber.trim() || null,
context: context.trim() || undefined,
codecs: codecs.join(","),
callGroup: callGroup.trim() || null,
});
if (!result.ok) {
setError(result.error);
return;
}
setEditing(false);
router.refresh();
});
}
function onCancel() {
setName(extension.name);
setCallerIdName(extension.callerIdName ?? "");
setCallerIdNumber(extension.callerIdNumber ?? "");
setContext(extension.context);
setCodecs(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean));
setCallGroup(extension.callGroup ?? "");
setError(null);
setEditing(false);
}
if (!editing) {
return (
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Nome</dt>
<dd className="mt-1 text-sm text-foreground">{extension.name}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Caller ID</dt>
<dd className="mt-1 text-sm text-foreground">
{extension.callerIdName || extension.callerIdNumber
? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim()
: "—"}
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Contexto</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.context}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Perfil Sofia</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.sofiaProfile}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Codecs</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.codecs}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Grupo de captura</dt>
<dd className="mt-1 text-sm text-foreground">{extension.callGroup ?? "— (nenhum)"}</dd>
</div>
<div className="sm:col-span-2">
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="h-4 w-4" aria-hidden />
Editar configuração
</Button>
</div>
</dl>
);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FieldLabel htmlFor="ext-edit-name">Nome</FieldLabel>
<Input id="ext-edit-name" value={name} onChange={(e) => setName(e.target.value)} disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ext-edit-context">Contexto</FieldLabel>
<Input id="ext-edit-context" value={context} onChange={(e) => setContext(e.target.value)} className="font-mono" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ext-edit-cid-name">Caller ID nome</FieldLabel>
<Input id="ext-edit-cid-name" value={callerIdName} onChange={(e) => setCallerIdName(e.target.value)} disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ext-edit-cid-number">Caller ID número</FieldLabel>
<Input
id="ext-edit-cid-number"
value={callerIdNumber}
onChange={(e) => setCallerIdNumber(e.target.value.replace(/[^0-9]/g, ""))}
className="font-mono"
disabled={pending}
/>
</div>
</div>
<div>
<FieldLabel>Codecs disponíveis</FieldLabel>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{AVAILABLE_CODECS.map((codec) => (
<label key={codec} className="flex items-center gap-1.5 text-sm text-foreground">
<input
type="checkbox"
checked={codecs.includes(codec)}
onChange={() => toggleCodec(codec)}
disabled={pending}
className="h-4 w-4 rounded border-input"
/>
<span className="font-mono">{codec}</span>
</label>
))}
</div>
</div>
<div className="sm:w-72">
<FieldLabel htmlFor="ext-edit-callgroup">Grupo de captura</FieldLabel>
<Input
id="ext-edit-callgroup"
list="ext-edit-callgroups"
value={callGroup}
onChange={(e) => setCallGroup(e.target.value)}
placeholder="Sem grupo"
className="font-mono"
disabled={pending}
/>
<datalist id="ext-edit-callgroups">
{existingCallGroups.map((group) => (
<option key={group} value={group} />
))}
</datalist>
<p className="mt-1.5 text-xs text-muted-foreground">
Escolha um grupo existente na lista ou digite um novo. Ramais no mesmo grupo podem capturar a chamada um
do outro (*8).
</p>
</div>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex gap-3">
<Button type="button" onClick={onSave} disabled={pending}>
{pending ? "Salvando…" : "Salvar"}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} disabled={pending}>
Cancelar
</Button>
</div>
</div>
);
}
/** Diferente de `ResetPasswordAction`: nunca troca a senha, só mostra a /** Diferente de `ResetPasswordAction`: nunca troca a senha, só mostra a
* atual de novo — pra reconfigurar um telefone/softphone sem invalidar * atual de novo — pra reconfigurar um telefone/softphone sem invalidar
* outro aparelho já usando a mesma credencial (achado real reportado pelo * outro aparelho já usando a mesma credencial (achado real reportado pelo
@@ -48,51 +234,6 @@ export function RevealPasswordAction({ extensionId }: { extensionId: string }) {
); );
} }
export function CallGroupEditAction({ extensionId, callGroup }: { extensionId: string; callGroup: string | null }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(callGroup ?? "");
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onSave() {
setError(null);
startTransition(async () => {
const result = await updateExtension(extensionId, { callGroup: value.trim() || null });
if (!result.ok) {
setError(result.error);
return;
}
setEditing(false);
router.refresh();
});
}
if (!editing) {
return (
<div className="flex items-center gap-3">
<span className="font-mono text-sm text-foreground">{callGroup ?? "— (nenhum)"}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
Editar
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Input value={value} onChange={(e) => setValue(e.target.value)} placeholder="Ex.: recepcao" className="h-8 w-48 font-mono" disabled={pending} />
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
{pending ? "…" : "Salvar"}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={pending}>
Cancelar
</Button>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
);
}
export function ResetPasswordAction({ extensionId }: { extensionId: string }) { export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);

View File

@@ -7,7 +7,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill"; import { Pill } from "@/components/ui/pill";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import type { Extension } from "@/lib/extension-types"; import type { Extension } from "@/lib/extension-types";
import { ResetPasswordAction, RevealPasswordAction, CallGroupEditAction, DeleteExtensionAction } from "./actions-panel"; import { ResetPasswordAction, RevealPasswordAction, ExtensionEditForm, DeleteExtensionAction } from "./actions-panel";
interface Me { interface Me {
tenant: { name: string; code: string } | null; tenant: { name: string; code: string } | null;
@@ -25,6 +25,8 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
throw err; throw err;
} }
const me = await apiFetch<Me>("/auth/me", session.accessToken); const me = await apiFetch<Me>("/auth/me", session.accessToken);
const allExtensions = await apiFetch<Extension[]>("/extensions", session.accessToken);
const existingCallGroups = [...new Set(allExtensions.map((e) => e.callGroup).filter((g): g is string => !!g))].sort();
return ( return (
<div className="mx-auto max-w-2xl space-y-5"> <div className="mx-auto max-w-2xl space-y-5">
@@ -46,55 +48,26 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
<span className="font-mono">{extension.domain}</span> <span className="font-mono">{extension.domain}</span>
</p> </p>
<Panel>
<PanelHeader title="Configuração" />
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 p-5 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Caller ID</dt>
<dd className="mt-1 text-sm text-foreground">
{extension.callerIdName || extension.callerIdNumber
? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim()
: "—"}
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Contexto</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.context}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Perfil Sofia</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.sofiaProfile}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Codecs</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.codecs}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Registros simultâneos</dt>
<dd className="mt-1 font-mono text-sm tabular-nums text-foreground">{extension.maxRegistrations}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Grupo de captura</dt>
<dd className="mt-1 text-sm text-foreground">
<CallGroupEditAction extensionId={extension.id} callGroup={extension.callGroup} />
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Criado em</dt>
<dd className="mt-1 text-sm text-foreground">{formatDate(extension.createdAt)}</dd>
</div>
</dl>
</Panel>
<Panel> <Panel>
<PanelHeader <PanelHeader
title="Senha SIP" title="Configuração e senha SIP"
description="Cifrada em repouso — dá pra ver a atual de novo (pra reconfigurar um aparelho) ou gerar uma nova." description="Edite os dados do ramal e veja/redefina a senha SIP no mesmo lugar."
/> />
<div className="space-y-4 p-5"> <div className="space-y-5 p-5">
<ExtensionEditForm extension={extension} existingCallGroups={existingCallGroups} />
<div className="flex flex-wrap gap-x-6 gap-y-1 border-t border-border pt-4 text-xs text-muted-foreground">
<span>
Registros simultâneos: <span className="font-mono tabular-nums text-foreground">{extension.maxRegistrations}</span>
</span>
<span>Criado em {formatDate(extension.createdAt)}</span>
</div>
<div className="border-t border-border pt-4">
<div className="space-y-4">
<RevealPasswordAction extensionId={extension.id} /> <RevealPasswordAction extensionId={extension.id} />
<ResetPasswordAction extensionId={extension.id} /> <ResetPasswordAction extensionId={extension.id} />
</div> </div>
</div>
</div>
</Panel> </Panel>
<Panel className="border-destructive/30"> <Panel className="border-destructive/30">

View File

@@ -74,8 +74,11 @@ export async function revealExtensionPassword(id: string): Promise<{ ok: true; s
} }
export interface UpdateExtensionInput { export interface UpdateExtensionInput {
callerIdName?: string; name?: string;
callerIdNumber?: string; callerIdName?: string | null;
callerIdNumber?: string | null;
context?: string;
codecs?: string;
maxRegistrations?: number; maxRegistrations?: number;
callGroup?: string | null; callGroup?: string | null;
} }

View File

@@ -1,3 +1,6 @@
/** Mesma lista de `apps/api/src/extensions/dto/create-extension.dto.ts::AVAILABLE_CODECS` — manter em sincronia. */
export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const;
export interface Extension { export interface Extension {
id: string; id: string;
tenantId: string; tenantId: string;

View File

@@ -30,6 +30,11 @@ export interface DirectoryUserParams {
* contra a documentação do FreeSWITCH antes de close (a primeira versão * contra a documentação do FreeSWITCH antes de close (a primeira versão
* usava `call-group`, que não é lido por nada). */ * usava `call-group`, que não é lido por nada). */
callGroup?: string | null; callGroup?: string | null;
/** Lista de codecs de áudio permitidos pro ramal, separados por vírgula
* (ex.: "PCMU,PCMA,OPUS") — vira `absolute_codec_string`, a variável
* que o FreeSWITCH usa pra restringir/ordenar os codecs oferecidos na
* negociação SDP pra este usuário especificamente. */
codecs?: string;
} }
/** /**
@@ -67,6 +72,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string {
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/> <variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/> <variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""} ${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
${params.codecs ? `<variable name="absolute_codec_string" value="${xmlEscape(params.codecs)}"/>` : ""}
</variables> </variables>
</user> </user>
</users> </users>