feat(frontend): Telefonia > Ramais — CRUD, reset de senha, isolamento por tenant
Tela /app/telefonia/ramais completa: listagem com busca/ordenacao, criacao com reveal de senha SIP (gerada pela API, mostrada uma unica vez), detalhe com redefinir senha (novo POST /extensions/:id/reset-password — unica forma de "editar" a senha, nunca reexpoe a existente) e remover (soft delete), confirmacao inline de 2 cliques em vez de modal. Cada tela deixa explicito de qual tenant os ramais sao, alem do isolamento por RLS que ja existia. Corrige de quebra um bug real que afeta qualquer acao futura sem payload: apiFetch sempre mandava Content-Type: application/json mesmo em requests sem body, e o parser do Fastify rejeita body vazio com esse header. Testado ponta a ponta com um tenant real (Acme Call Center): criar ramal, revelar senha, redefinir (senha nova confirmada diferente da original), remover, lista voltando vazia. Dark mode e mobile conferidos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||
import { resetExtensionPassword, deleteExtension } from "../actions";
|
||||
|
||||
export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
function onClick() {
|
||||
if (!confirming) {
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await resetExtensionPassword(extensionId);
|
||||
setConfirming(false);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setNewPassword(result.sipPassword);
|
||||
});
|
||||
}
|
||||
|
||||
if (newPassword) {
|
||||
return <SecretReveal label="Nova senha SIP" value={newPassword} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button type="button" variant="outline" onClick={onClick} disabled={pending}>
|
||||
<KeyRound className="h-4 w-4" aria-hidden />
|
||||
{pending ? "Gerando…" : confirming ? "Confirmar — gerar nova senha agora" : "Redefinir senha"}
|
||||
</Button>
|
||||
{confirming && !pending && (
|
||||
<p className="text-xs text-muted-foreground">O ramal para de registrar com a senha antiga assim que confirmar.</p>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteExtensionAction({ extensionId, extensionNumber }: { extensionId: string; extensionNumber: string }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
function onClick() {
|
||||
if (!confirming) {
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await deleteExtension(extensionId);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
router.push("/app/telefonia/ramais");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button type="button" variant="destructive" onClick={onClick} disabled={pending}>
|
||||
<Trash2 className="h-4 w-4" aria-hidden />
|
||||
{pending ? "Removendo…" : confirming ? `Confirmar remoção do ramal ${extensionNumber}` : "Remover ramal"}
|
||||
</Button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
101
apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx
Normal file
101
apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
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, DeleteExtensionAction } from "./actions-panel";
|
||||
|
||||
interface Me {
|
||||
tenant: { name: string; code: string } | null;
|
||||
}
|
||||
|
||||
export default async function ExtensionDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await requireSession();
|
||||
|
||||
let extension: Extension;
|
||||
try {
|
||||
extension = await apiFetch<Extension>(`/extensions/${id}`, session.accessToken);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) notFound();
|
||||
throw err;
|
||||
}
|
||||
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-5">
|
||||
<Link
|
||||
href="/app/telefonia/ramais"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden />
|
||||
Ramais
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-lg font-semibold text-foreground">{extension.number}</h1>
|
||||
<span className="text-lg text-muted-foreground">{extension.name}</span>
|
||||
<Pill tone={extension.enabled ? "accent" : "neutral"}>{extension.enabled ? "Ativo" : "Desabilitado"}</Pill>
|
||||
</div>
|
||||
<p className="-mt-3 text-sm text-muted-foreground">
|
||||
Tenant <strong>{me.tenant?.name ?? extension.tenantId}</strong> · domínio SIP{" "}
|
||||
<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">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, nunca reexibida — a única forma de trocá-la é gerar uma nova."
|
||||
/>
|
||||
<div className="p-5">
|
||||
<ResetPasswordAction extensionId={extension.id} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="border-destructive/30">
|
||||
<PanelHeader title="Zona de risco" description="Remover um ramal desliga o registro SIP e não pode ser desfeito por aqui." />
|
||||
<div className="p-5">
|
||||
<DeleteExtensionAction extensionId={extension.id} extensionNumber={extension.number} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/frontend/src/app/app/telefonia/ramais/actions.ts
Normal file
69
apps/frontend/src/app/app/telefonia/ramais/actions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
} catch {
|
||||
// corpo não era JSON — usa a mensagem crua
|
||||
}
|
||||
return err.message || "Falha inesperada na API.";
|
||||
}
|
||||
return "Falha inesperada. Tente novamente.";
|
||||
}
|
||||
|
||||
export interface CreateExtensionInput {
|
||||
number: string;
|
||||
name: string;
|
||||
callerIdName?: string;
|
||||
callerIdNumber?: string;
|
||||
context?: string;
|
||||
maxRegistrations?: number;
|
||||
}
|
||||
|
||||
export async function createExtension(
|
||||
input: CreateExtensionInput,
|
||||
): Promise<{ ok: true; extension: Extension; sipPassword: string } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const result = await apiFetch<Extension & { sipPassword: string }>("/extensions", session.accessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const { sipPassword, ...extension } = result;
|
||||
revalidatePath("/app/telefonia/ramais");
|
||||
return { ok: true, extension, sipPassword };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetExtensionPassword(id: string): Promise<{ ok: true; sipPassword: string } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const { sipPassword } = await apiFetch<{ sipPassword: string }>(`/extensions/${id}/reset-password`, session.accessToken, {
|
||||
method: "POST",
|
||||
});
|
||||
return { ok: true, sipPassword };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExtension(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
await apiFetch<void>(`/extensions/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/telefonia/ramais");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
134
apps/frontend/src/app/app/telefonia/ramais/new/form.tsx
Normal file
134
apps/frontend/src/app/app/telefonia/ramais/new/form.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, Phone } from "lucide-react";
|
||||
import { Panel } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, FieldLabel } from "@/components/ui/input";
|
||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||
import { createExtension } from "../actions";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
|
||||
export function NewExtensionForm() {
|
||||
const [number, setNumber] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [callerIdName, setCallerIdName] = useState("");
|
||||
const [callerIdNumber, setCallerIdNumber] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [created, setCreated] = useState<{ extension: Extension; sipPassword: string } | null>(null);
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!/^[0-9]{2,10}$/.test(number)) {
|
||||
setError("Ramal deve ter só dígitos (2 a 10).");
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError("Dê um nome ao ramal.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createExtension({
|
||||
number,
|
||||
name: name.trim(),
|
||||
callerIdName: callerIdName.trim() || undefined,
|
||||
callerIdNumber: callerIdNumber.trim() || undefined,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setCreated(result);
|
||||
});
|
||||
}
|
||||
|
||||
if (created) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Panel className="flex items-center gap-3 p-5">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-status-green/15 text-status-green">
|
||||
<Check className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Ramal <span className="font-mono">{created.extension.number}</span> criado
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{created.extension.name}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<SecretReveal label="Senha SIP" value={created.sipPassword} />
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/app/telefonia/ramais">Voltar pra lista</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/app/telefonia/ramais/${created.extension.id}`}>Ver ramal</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-5">
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[140px_1fr]">
|
||||
<div>
|
||||
<FieldLabel htmlFor="ext-number">Ramal</FieldLabel>
|
||||
<div className="relative">
|
||||
<Phone className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
id="ext-number"
|
||||
value={number}
|
||||
onChange={(e) => setNumber(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
placeholder="1500"
|
||||
className="pl-8 font-mono"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="ext-name">Nome</FieldLabel>
|
||||
<Input id="ext-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Recepção" disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="ext-cid-name">Caller ID — nome (opcional)</FieldLabel>
|
||||
<Input id="ext-cid-name" value={callerIdName} onChange={(e) => setCallerIdName(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="ext-cid-number">Caller ID — número (opcional)</FieldLabel>
|
||||
<Input
|
||||
id="ext-cid-number"
|
||||
value={callerIdNumber}
|
||||
onChange={(e) => setCallerIdNumber(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
className="font-mono"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{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 justify-end gap-3">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar ramal"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
24
apps/frontend/src/app/app/telefonia/ramais/new/page.tsx
Normal file
24
apps/frontend/src/app/app/telefonia/ramais/new/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { NewExtensionForm } from "./form";
|
||||
|
||||
export default function NewExtensionPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-5">
|
||||
<Link
|
||||
href="/app/telefonia/ramais"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden />
|
||||
Ramais
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Novo ramal</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
A senha SIP é gerada automaticamente e mostrada uma única vez depois de criar.
|
||||
</p>
|
||||
</div>
|
||||
<NewExtensionForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/frontend/src/app/app/telefonia/ramais/page.tsx
Normal file
18
apps/frontend/src/app/app/telefonia/ramais/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import { RamaisView } from "./ramais-view";
|
||||
|
||||
interface Me {
|
||||
tenant: { name: string; code: string } | null;
|
||||
}
|
||||
|
||||
export default async function RamaisPage() {
|
||||
const session = await requireSession();
|
||||
const [extensions, me] = await Promise.all([
|
||||
apiFetch<Extension[]>("/extensions", session.accessToken),
|
||||
apiFetch<Me>("/auth/me", session.accessToken),
|
||||
]);
|
||||
|
||||
return <RamaisView extensions={extensions} tenantName={me.tenant?.name ?? ""} />;
|
||||
}
|
||||
124
apps/frontend/src/app/app/telefonia/ramais/ramais-view.tsx
Normal file
124
apps/frontend/src/app/app/telefonia/ramais/ramais-view.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpDown, Phone, Plus, Search } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
|
||||
export function RamaisView({ extensions, tenantName }: { extensions: Extension[]; tenantName: string }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = extensions.filter((ext) => ext.number.includes(q) || ext.name.toLowerCase().includes(q));
|
||||
return filtered.sort((a, b) => (sortDesc ? b.number.localeCompare(a.number) : a.number.localeCompare(b.number)));
|
||||
}, [extensions, query, sortDesc]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Ramais</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Ramais SIP de <strong>{tenantName || "este tenant"}</strong> — nunca visíveis nem alcançáveis por
|
||||
nenhum outro tenant (isolamento por RLS, agente.md secao 30). A senha SIP nunca é reexibida depois de
|
||||
criada; use <strong>Redefinir senha</strong> no ramal pra gerar uma nova.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/app/telefonia/ramais/new">
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
Novo ramal
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Ramais cadastrados" description={`${extensions.length} ramal(is) neste tenant`} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||
<div className="relative w-full max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por ramal ou nome…"
|
||||
className="pl-8"
|
||||
aria-label="Buscar ramal"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortDesc((d) => !d)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
|
||||
{sortDesc ? "Maior ramal primeiro" : "Menor ramal primeiro"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={extensions.length > 0 ? "Nenhum ramal bate com essa busca" : "Nenhum ramal cadastrado ainda"}
|
||||
description={extensions.length > 0 ? "Tente outro termo de busca." : "Crie o primeiro ramal SIP deste tenant."}
|
||||
action={
|
||||
extensions.length === 0 && (
|
||||
<Button asChild>
|
||||
<Link href="/app/telefonia/ramais/new">
|
||||
<Plus className="h-4 w-4" aria-hidden /> Criar o primeiro ramal
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH onClick={() => setSortDesc((d) => !d)} aria-sort={sortDesc ? "descending" : "ascending"}>
|
||||
Ramal
|
||||
</TH>
|
||||
<TH>Nome</TH>
|
||||
<TH>Caller ID</TH>
|
||||
<TH>Registros</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Criado</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((ext) => (
|
||||
<TR key={ext.id}>
|
||||
<TD>
|
||||
<Link
|
||||
href={`/app/telefonia/ramais/${ext.id}`}
|
||||
className="flex items-center gap-2 font-mono font-medium text-foreground underline-offset-4 hover:text-primary hover:underline focus-visible:underline"
|
||||
>
|
||||
<Phone className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{ext.number}
|
||||
</Link>
|
||||
</TD>
|
||||
<TD>{ext.name}</TD>
|
||||
<TD className="text-muted-foreground">
|
||||
{ext.callerIdName || ext.callerIdNumber ? `${ext.callerIdName ?? ""} ${ext.callerIdNumber ? `<${ext.callerIdNumber}>` : ""}`.trim() : "—"}
|
||||
</TD>
|
||||
<TD className="font-mono tabular-nums">{ext.maxRegistrations}</TD>
|
||||
<TD>
|
||||
<Pill tone={ext.enabled ? "accent" : "neutral"}>{ext.enabled ? "Ativo" : "Desabilitado"}</Pill>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(ext.createdAt)}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user