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
This commit is contained in:
@@ -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,54 +48,25 @@ 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">
|
||||
<RevealPasswordAction extensionId={extension.id} />
|
||||
<ResetPasswordAction extensionId={extension.id} />
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user