Compare commits
4 Commits
9d3c84064f
...
6c23513f42
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c23513f42 | |||
| dd095077dd | |||
| 3fe571bfb0 | |||
| e75872faec |
146
TODO.md
146
TODO.md
@@ -2598,6 +2598,152 @@ problema")
|
||||
atual" de um ramal já existente) mostram o ícone verde de sucesso
|
||||
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
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 {
|
||||
@IsString()
|
||||
@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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
@@ -55,6 +68,16 @@ export class UpdateExtensionDto {
|
||||
@Matches(/^[0-9]{2,20}$/)
|
||||
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()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
||||
@@ -209,8 +209,11 @@ export class ExtensionsController {
|
||||
tx.extension.updateMany({
|
||||
where: { id, tenantId, deletedAt: null },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
|
||||
...(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.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
|
||||
tenantId: extension.tenantId,
|
||||
extensionId: extension.id,
|
||||
callGroup: extension.callGroup,
|
||||
codecs: extension.codecs,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,12 +2,198 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Input, FieldLabel } from "@/components/ui/input";
|
||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||
import { AVAILABLE_CODECS, type Extension } from "@/lib/extension-types";
|
||||
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 já 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
|
||||
* atual de novo — pra reconfigurar um telefone/softphone sem invalidar
|
||||
* 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 }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { formatDate } from "@/lib/format";
|
||||
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 {
|
||||
tenant: { name: string; code: string } | null;
|
||||
@@ -25,6 +25,8 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
|
||||
throw err;
|
||||
}
|
||||
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 (
|
||||
<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>
|
||||
</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>
|
||||
<PanelHeader
|
||||
title="Senha SIP"
|
||||
description="Cifrada em repouso — dá pra ver a atual de novo (pra reconfigurar um aparelho) ou gerar uma nova."
|
||||
title="Configuração e senha SIP"
|
||||
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} />
|
||||
<ResetPasswordAction extensionId={extension.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="border-destructive/30">
|
||||
|
||||
@@ -74,8 +74,11 @@ export async function revealExtensionPassword(id: string): Promise<{ ok: true; s
|
||||
}
|
||||
|
||||
export interface UpdateExtensionInput {
|
||||
callerIdName?: string;
|
||||
callerIdNumber?: string;
|
||||
name?: string;
|
||||
callerIdName?: string | null;
|
||||
callerIdNumber?: string | null;
|
||||
context?: string;
|
||||
codecs?: string;
|
||||
maxRegistrations?: number;
|
||||
callGroup?: string | null;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface DirectoryUserParams {
|
||||
* contra a documentação do FreeSWITCH antes de close (a primeira versão
|
||||
* usava `call-group`, que não é lido por nada). */
|
||||
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_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
||||
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
|
||||
${params.codecs ? `<variable name="absolute_codec_string" value="${xmlEscape(params.codecs)}"/>` : ""}
|
||||
</variables>
|
||||
</user>
|
||||
</users>
|
||||
|
||||
Reference in New Issue
Block a user