feat: add frontend, nginx reverse proxy and monitoring/reports extras
- apps/frontend: Next.js 15 (App Router) + Tailwind v4 + componentes estilo shadcn/ui sobre Radix UI + TanStack Query. Tema light/dark, logo processada. Menu completo (secao 52) com gating por permissao real. Todas as telas do checklist de aceite (secao 90) conectadas a endpoints reais (nao mockup): login, usuarios, perfis/permissoes, ramais/troncos, dialplan, filas/agentes, console do agente, campanhas (CPS/CSV/ iniciar/pausar), monitoramento ao vivo (polling, nao WebSocket real), TME/TMA, busca/export de chamadas, administracao do Asterisk, auditoria - infrastructure/nginx: reverse proxy colocando frontend+API na mesma origem (porta 80), antecipado da Fase 9 pois a API nao publica porta propria - apps/api: GET /api/monitoring/agents (estado corrente real via agent_state_events em aberto) e filtro queueId em GET /api/reports/calls Pendencia registrada: tela de Callbacks nao implementada (schema existe desde a Fase 6, mas nunca houve controller/service — construir a tela sem API real seria mockup). Verificacao visual em navegador nao foi possivel neste ambiente headless; validado via tsc/eslint/next build limpos + curl reproduzindo as chamadas do navegador (middleware de auth, 24 paginas protegidas via Nginx, endpoints de dados com cookie de sessao).
This commit is contained in:
296
apps/frontend/src/app/(app)/ramais/page.tsx
Normal file
296
apps/frontend/src/app/(app)/ramais/page.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, KeyRound, Trash2, Pencil, Copy } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { RequirePermission } from '@/components/require-permission';
|
||||
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { extensionsService, type ExtensionInput } from '@/services/extensions';
|
||||
import type { Extension } from '@/types';
|
||||
import { errorMessage } from '@/lib/error-message';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
function ExtensionFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
extension,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
extension: Extension | null;
|
||||
}) {
|
||||
const isEdit = Boolean(extension);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [form, setForm] = React.useState<ExtensionInput>({
|
||||
number: '',
|
||||
name: '',
|
||||
callerId: '',
|
||||
enabled: true,
|
||||
});
|
||||
const [createdPassword, setCreatedPassword] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setCreatedPassword(null);
|
||||
setForm(
|
||||
extension
|
||||
? {
|
||||
name: extension.name,
|
||||
callerId: extension.callerId ?? '',
|
||||
enabled: extension.enabled,
|
||||
}
|
||||
: { number: '', name: '', callerId: '', enabled: true },
|
||||
);
|
||||
}
|
||||
}, [open, extension]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
isEdit && extension
|
||||
? extensionsService.update(extension.id, form)
|
||||
: extensionsService.create(form),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
||||
if (!isEdit && 'sipPassword' in result && result.sipPassword) {
|
||||
setCreatedPassword(result.sipPassword);
|
||||
toast({ title: 'Ramal criado', variant: 'success' });
|
||||
} else {
|
||||
toast({ title: 'Ramal salvo', variant: 'success' });
|
||||
onOpenChange(false);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Editar ramal' : 'Novo ramal'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{createdPassword ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Senha SIP gerada (exibida apenas uma vez, copie agora):
|
||||
</p>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-2 font-mono text-sm">
|
||||
<span className="flex-1 break-all">{createdPassword}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => navigator.clipboard.writeText(createdPassword)}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{!isEdit && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="number">Número</Label>
|
||||
<Input
|
||||
id="number"
|
||||
required
|
||||
value={form.number ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, number: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="name">Nome</Label>
|
||||
<Input
|
||||
id="name"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="callerId">Caller ID</Label>
|
||||
<Input
|
||||
id="callerId"
|
||||
value={form.callerId ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" loading={mutation.isPending}>
|
||||
Salvar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionsContent() {
|
||||
const { can } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const debouncedSearch = useDebounce(search);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<Extension | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['extensions'],
|
||||
queryFn: extensionsService.list,
|
||||
});
|
||||
|
||||
const resetPassword = useMutation({
|
||||
mutationFn: (id: string) => extensionsService.resetPassword(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
||||
toast({ title: 'Senha SIP redefinida', variant: 'success' });
|
||||
},
|
||||
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => extensionsService.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
||||
toast({ title: 'Ramal removido', variant: 'success' });
|
||||
},
|
||||
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const filtered = (data ?? []).filter(
|
||||
(e) =>
|
||||
e.number.includes(debouncedSearch) ||
|
||||
e.name.toLowerCase().includes(debouncedSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const columns: DataTableColumn<Extension>[] = [
|
||||
{ key: 'number', header: 'Ramal', render: (r) => r.number },
|
||||
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||
{ key: 'context', header: 'Contexto', render: (r) => r.context },
|
||||
{
|
||||
key: 'enabled',
|
||||
header: 'Status',
|
||||
render: (r) => (
|
||||
<Badge variant={r.enabled ? 'success' : 'secondary'}>
|
||||
{r.enabled ? 'Ativo' : 'Inativo'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'updatedAt', header: 'Atualizado em', render: (r) => formatDateTime(r.updatedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'text-right',
|
||||
render: (r) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
{can('extensions.update') && (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (confirm(`Redefinir a senha SIP do ramal ${r.number}?`)) {
|
||||
resetPassword.mutate(r.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{can('extensions.delete') && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(`Remover o ramal ${r.number}?`)) remove.mutate(r.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Ramais"
|
||||
description="Ramais SIP dos agentes (PJSIP)"
|
||||
actions={
|
||||
can('extensions.create') && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus /> Novo ramal
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={() => refetch()}
|
||||
rowKey={(r) => r.id}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Buscar por número ou nome..."
|
||||
emptyMessage="Nenhum ramal cadastrado."
|
||||
/>
|
||||
<ExtensionFormDialog open={dialogOpen} onOpenChange={setDialogOpen} extension={editing} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExtensionsPage() {
|
||||
return (
|
||||
<RequirePermission permission="extensions.view">
|
||||
<ExtensionsContent />
|
||||
</RequirePermission>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user