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:
2026-08-27 17:35:34 -03:00
parent 0a8b830e2c
commit 6273b32214
100 changed files with 12280 additions and 17 deletions

View File

@@ -0,0 +1,213 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PhoneCall, Coffee, LogOut, Play } from 'lucide-react';
import { PageHeader } from '@/components/layout/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/ui/toast';
import { agentConsoleService } from '@/services/agent-console';
import { pauseReasonsService } from '@/services/pause-reasons';
import { ApiError } from '@/lib/api-client';
import { errorMessage } from '@/lib/error-message';
import type { AgentState } from '@/types';
const STATE_LABEL: Record<AgentState, string> = {
OFFLINE: 'Offline',
LOGGED_OUT: 'Deslogado',
LOGGED_IN: 'Logado (ocioso)',
AVAILABLE: 'Disponível',
RINGING: 'Chamando',
IN_CALL: 'Em chamada',
WRAP_UP: 'Pós-atendimento',
PAUSED: 'Pausado',
};
const STATE_VARIANT: Record<AgentState, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
OFFLINE: 'outline',
LOGGED_OUT: 'outline',
LOGGED_IN: 'secondary',
AVAILABLE: 'success',
RINGING: 'warning',
IN_CALL: 'warning',
WRAP_UP: 'warning',
PAUSED: 'destructive',
};
export default function AgentConsolePage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [extension, setExtension] = React.useState('');
const [pauseReasonId, setPauseReasonId] = React.useState('');
const { data: me, isLoading, error } = useQuery({
queryKey: ['agent-console-me'],
queryFn: agentConsoleService.me,
retry: false,
});
const { data: pauseReasons } = useQuery({
queryKey: ['pause-reasons'],
queryFn: pauseReasonsService.list,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['agent-console-me'] });
const onErr = (err: unknown) =>
toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' });
const login = useMutation({
mutationFn: () => agentConsoleService.login(extension),
onSuccess: () => {
invalidate();
toast({ title: 'Login realizado', variant: 'success' });
},
onError: onErr,
});
const available = useMutation({
mutationFn: () => agentConsoleService.available(),
onSuccess: () => {
invalidate();
toast({ title: 'Você está disponível', variant: 'success' });
},
onError: onErr,
});
const pause = useMutation({
mutationFn: () => agentConsoleService.pause(pauseReasonId),
onSuccess: () => {
invalidate();
toast({ title: 'Pausa iniciada', variant: 'success' });
},
onError: onErr,
});
const unpause = useMutation({
mutationFn: () => agentConsoleService.unpause(),
onSuccess: () => {
invalidate();
toast({ title: 'Pausa encerrada', variant: 'success' });
},
onError: onErr,
});
const logout = useMutation({
mutationFn: () => agentConsoleService.logout(),
onSuccess: () => {
invalidate();
toast({ title: 'Logout realizado', variant: 'success' });
},
onError: onErr,
});
if (isLoading) {
return (
<>
<PageHeader title="Console do Agente" />
<Skeleton className="h-64 w-full" />
</>
);
}
if (error instanceof ApiError && error.status === 403) {
return (
<>
<PageHeader title="Console do Agente" />
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
Seu usuário não possui um agente de Call Center associado.
</CardContent>
</Card>
</>
);
}
const state = me?.state ?? 'OFFLINE';
return (
<>
<PageHeader title="Console do Agente" description={me?.agent.name} />
<Card className="max-w-lg">
<CardHeader>
<CardTitle className="flex items-center justify-between">
Status atual
<Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{me?.currentPause && (
<p className="text-sm text-muted-foreground">
Pausado desde {new Date(me.currentPause.startedAt).toLocaleTimeString('pt-BR')} {' '}
{me.currentPause.pauseReason.name}
</p>
)}
{state === 'OFFLINE' || state === 'LOGGED_OUT' ? (
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="extension">Ramal</Label>
<Input
id="extension"
value={extension}
onChange={(e) => setExtension(e.target.value)}
placeholder="4001"
/>
</div>
<Button loading={login.isPending} disabled={!extension} onClick={() => login.mutate()}>
<Play /> Entrar
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
{(state === 'LOGGED_IN' || state === 'PAUSED') && (
<Button loading={available.isPending} onClick={() => available.mutate()}>
<PhoneCall /> {state === 'PAUSED' ? 'Encerrar pausa e ficar disponível' : 'Ficar disponível'}
</Button>
)}
{state === 'PAUSED' ? (
<Button variant="outline" loading={unpause.isPending} onClick={() => unpause.mutate()}>
Retirar pausa
</Button>
) : (
state !== 'IN_CALL' &&
state !== 'RINGING' && (
<div className="flex items-center gap-2">
<Select value={pauseReasonId} onValueChange={setPauseReasonId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Motivo da pausa..." />
</SelectTrigger>
<SelectContent>
{(pauseReasons ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
disabled={!pauseReasonId}
loading={pause.isPending}
onClick={() => pause.mutate()}
>
<Coffee /> Pausar
</Button>
</div>
)
)}
<Button variant="destructive" loading={logout.isPending} onClick={() => logout.mutate()}>
<LogOut /> Logout do agente
</Button>
</div>
)}
</CardContent>
</Card>
</>
);
}

View File

@@ -0,0 +1,262 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil } 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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { agentsService, type AgentInput } from '@/services/agents';
import { usersService } from '@/services/users';
import type { Agent } from '@/types';
import { errorMessage } from '@/lib/error-message';
function AgentFormDialog({
open,
onOpenChange,
agent,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
agent: Agent | null;
}) {
const isEdit = Boolean(agent);
const queryClient = useQueryClient();
const { toast } = useToast();
const [form, setForm] = React.useState<AgentInput>({ code: '', name: '', userId: '' });
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: usersService.list,
enabled: open && !isEdit,
});
const { data: existingAgents } = useQuery({
queryKey: ['agents'],
queryFn: agentsService.list,
enabled: open && !isEdit,
});
React.useEffect(() => {
if (open) {
setForm(agent ? { name: agent.name, active: agent.active } : { code: '', name: '', userId: '' });
}
}, [open, agent]);
const linkedUserIds = new Set((existingAgents ?? []).map((a) => a.userId));
const availableUsers = (users ?? []).filter((u) => !linkedUserIds.has(u.id));
const mutation = useMutation({
mutationFn: () =>
isEdit && agent ? agentsService.update(agent.id, form) : agentsService.create(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
toast({ title: 'Agente 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 agente' : 'Novo agente'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
{!isEdit && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="code">Código</Label>
<Input
id="code"
required
value={form.code ?? ''}
onChange={(e) => setForm((f) => ({ ...f, code: 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>
{!isEdit && (
<div className="flex flex-col gap-1.5">
<Label>Usuário vinculado</Label>
<Select
value={form.userId}
onValueChange={(v) => setForm((f) => ({ ...f, userId: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um usuário..." />
</SelectTrigger>
<SelectContent>
{availableUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function AgentsContent() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editing, setEditing] = React.useState<Agent | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['agents'],
queryFn: agentsService.list,
});
const remove = useMutation({
mutationFn: (id: string) => agentsService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
toast({ title: 'Agente removido', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<Agent>[] = [
{ key: 'code', header: 'Código', render: (r) => r.code },
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'user', header: 'Usuário', render: (r) => r.user?.email ?? '—' },
{
key: 'queues',
header: 'Filas',
render: (r) => (
<div className="flex flex-wrap gap-1">
{(r.queues ?? []).map((q) => (
<Badge key={q.queue.id} variant="secondary">
{q.queue.name}
</Badge>
))}
{(r.queues ?? []).length === 0 && '—'}
</div>
),
},
{
key: 'active',
header: 'Status',
render: (r) => (
<Badge variant={r.active ? 'success' : 'secondary'}>{r.active ? 'Ativo' : 'Inativo'}</Badge>
),
},
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
{can('agents.update') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
)}
{can('agents.delete') && (
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Remover o agente ${r.name}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
),
},
];
return (
<>
<PageHeader
title="Agentes"
description="Agentes de Call Center e sua associação a filas"
actions={
can('agents.create') && (
<Button
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus /> Novo agente
</Button>
)
}
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhum agente cadastrado."
/>
<AgentFormDialog open={dialogOpen} onOpenChange={setDialogOpen} agent={editing} />
</>
);
}
export default function AgentsPage() {
return (
<RequirePermission permission="agents.view">
<AgentsContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { RefreshCw, Terminal } from 'lucide-react';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { asteriskService } from '@/services/asterisk';
import { errorMessage } from '@/lib/error-message';
function Content() {
const { can } = useAuth();
const { toast } = useToast();
const [selectedCommand, setSelectedCommand] = React.useState('');
const [output, setOutput] = React.useState<string | null>(null);
const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
queryKey: ['asterisk-status'],
queryFn: asteriskService.status,
refetchInterval: 15_000,
});
const { data: allowedCommands } = useQuery({
queryKey: ['asterisk-allowed-commands'],
queryFn: asteriskService.allowedCommands,
});
const runDiagnostic = useMutation({
mutationFn: (command: string) => asteriskService.runDiagnostic(command),
onSuccess: (result) => setOutput(result.output),
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const reload = useMutation({
mutationFn: () => asteriskService.reload(),
onSuccess: () => toast({ title: 'Reload solicitado', variant: 'success' }),
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
return (
<>
<PageHeader
title="Asterisk"
description="Status da conexão AMI e diagnóstico do servidor Asterisk"
actions={
can('asterisk.reload') && (
<Button variant="outline" loading={reload.isPending} onClick={() => reload.mutate()}>
<RefreshCw /> Reload
</Button>
)
}
/>
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card>
<CardContent className="flex items-center justify-between pt-5">
<div>
<p className="text-xs text-muted-foreground">Conexão AMI (controle)</p>
<p className="mt-1 text-sm font-medium">
{statusLoading ? '...' : status?.amiControlConnection}
</p>
</div>
<Badge variant={status?.amiControlConnection === 'up' ? 'success' : 'destructive'}>
{status?.amiControlConnection ?? '—'}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between pt-5">
<div>
<p className="text-xs text-muted-foreground">apps/asterisk-events (heartbeat)</p>
<p className="mt-1 text-sm font-medium">{status?.lastHeartbeat ?? 'sem sinal'}</p>
</div>
<Badge variant={status?.asteriskEventsHeartbeat === 'up' ? 'success' : 'destructive'}>
{status?.asteriskEventsHeartbeat ?? '—'}
</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Diagnóstico (comandos AMI allowlist)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Select value={selectedCommand} onValueChange={setSelectedCommand}>
<SelectTrigger className="w-80">
<SelectValue placeholder="Selecione um comando..." />
</SelectTrigger>
<SelectContent>
{(allowedCommands ?? []).map((cmd) => (
<SelectItem key={cmd} value={cmd}>
{cmd}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedCommand}
loading={runDiagnostic.isPending}
onClick={() => runDiagnostic.mutate(selectedCommand)}
>
<Terminal /> Executar
</Button>
</div>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-4 font-mono text-xs">
{output ?? 'A saída do comando aparecerá aqui.'}
</pre>
</CardContent>
</Card>
<div className="mt-4">
<Button variant="ghost" size="sm" onClick={() => refetchStatus()}>
<RefreshCw className="size-3.5" /> Atualizar status
</Button>
</div>
</>
);
}
export default function AsteriskPage() {
return (
<RequirePermission permission="asterisk.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Eye } 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 { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { auditService } from '@/services/audit';
import type { AuditLogEntry } from '@/types';
import { formatDateTime } from '@/lib/utils';
function Content() {
const [action, setAction] = React.useState('');
const [entityType, setEntityType] = React.useState('');
const [from, setFrom] = React.useState('');
const [to, setTo] = React.useState('');
const [page, setPage] = React.useState(1);
const [detail, setDetail] = React.useState<AuditLogEntry | null>(null);
const query = {
action: action || undefined,
entityType: entityType || undefined,
from: from || undefined,
to: to || undefined,
};
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['audit', query, page],
queryFn: () => auditService.query({ ...query, page, pageSize: 25 }),
});
const columns: DataTableColumn<AuditLogEntry>[] = [
{ key: 'createdAt', header: 'Data', render: (r) => formatDateTime(r.createdAt) },
{ key: 'user', header: 'Usuário', render: (r) => r.user?.email ?? r.userId ?? '—' },
{ key: 'action', header: 'Ação', render: (r) => r.action },
{ key: 'entityType', header: 'Entidade', render: (r) => r.entityType ?? '—' },
{ key: 'ipAddress', header: 'IP', render: (r) => r.ipAddress ?? '—' },
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<Button size="icon" variant="ghost" onClick={() => setDetail(r)}>
<Eye className="size-4" />
</Button>
),
},
];
return (
<>
<PageHeader title="Auditoria" description="Trilha de auditoria de ações no sistema" />
<Card className="mb-4">
<CardContent className="grid grid-cols-2 gap-3 pt-5 sm:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label>Ação</Label>
<Input value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Entidade</Label>
<Input value={entityType} onChange={(e) => { setEntityType(e.target.value); setPage(1); }} />
</div>
<div className="flex flex-col gap-1.5">
<Label>De</Label>
<Input type="datetime-local" value={from} onChange={(e) => { setFrom(e.target.value); setPage(1); }} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Até</Label>
<Input type="datetime-local" value={to} onChange={(e) => { setTo(e.target.value); setPage(1); }} />
</div>
</CardContent>
</Card>
<DataTable
columns={columns}
data={data?.items}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
page={data?.page ?? page}
pageSize={data?.pageSize ?? 25}
total={data?.total ?? 0}
onPageChange={setPage}
emptyMessage="Nenhum registro de auditoria encontrado."
/>
<Dialog open={Boolean(detail)} onOpenChange={(v) => !v && setDetail(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Detalhes do evento</DialogTitle>
</DialogHeader>
{detail && (
<div className="flex flex-col gap-3 text-sm">
<p><strong>Ação:</strong> {detail.action}</p>
<p><strong>Entidade:</strong> {detail.entityType ?? '—'} {detail.entityId ? `(${detail.entityId})` : ''}</p>
<p><strong>Usuário:</strong> {detail.user?.email ?? detail.userId ?? '—'}</p>
<p><strong>IP:</strong> {detail.ipAddress ?? '—'}</p>
<p><strong>User agent:</strong> {detail.userAgent ?? '—'}</p>
<div>
<p className="mb-1 font-medium">Antes</p>
<pre className="max-h-40 overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
{JSON.stringify(detail.before, null, 2) || '—'}
</pre>
</div>
<div>
<p className="mb-1 font-medium">Depois</p>
<pre className="max-h-40 overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
{JSON.stringify(detail.after, null, 2) || '—'}
</pre>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
export default function AuditPage() {
return (
<RequirePermission permission="audit.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,202 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, UploadCloud } 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 { 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 { suppressionService } from '@/services/suppression';
import type { SuppressionEntry } from '@/types';
import { errorMessage } from '@/lib/error-message';
import { formatDateTime } from '@/lib/utils';
function AddDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [phone, setPhone] = React.useState('');
const [reason, setReason] = React.useState('');
React.useEffect(() => {
if (open) {
setPhone('');
setReason('');
}
}, [open]);
const mutation = useMutation({
mutationFn: () => suppressionService.add(phone, reason || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['suppression'] });
toast({ title: 'Número bloqueado', variant: 'success' });
onOpenChange(false);
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bloquear número</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="phone">Telefone</Label>
<Input id="phone" required value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="reason">Motivo (opcional)</Label>
<Input id="reason" value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Bloquear
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function SuppressionContent() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = React.useState('');
const debouncedSearch = useDebounce(search);
const [page, setPage] = React.useState(1);
const [dialogOpen, setDialogOpen] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['suppression', debouncedSearch, page],
queryFn: () => suppressionService.query({ search: debouncedSearch || undefined, page, pageSize: 25 }),
});
const remove = useMutation({
mutationFn: (id: string) => suppressionService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['suppression'] });
toast({ title: 'Número desbloqueado', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const importCsv = useMutation({
mutationFn: (file: File) => suppressionService.importCsv(file),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['suppression'] });
toast({
title: 'Importação concluída',
description: `${result.added} adicionados, ${result.invalid} inválidos de ${result.total}.`,
variant: 'success',
});
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<SuppressionEntry>[] = [
{ key: 'phoneNormalized', header: 'Telefone', render: (r) => r.phoneNormalized },
{ key: 'reason', header: 'Motivo', render: (r) => r.reason ?? '—' },
{ key: 'createdAt', header: 'Adicionado em', render: (r) => formatDateTime(r.createdAt) },
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) =>
can('campaigns.update') ? (
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Desbloquear ${r.phoneNormalized}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
) : null,
},
];
return (
<>
<PageHeader
title="Lista de Bloqueio"
description="Números que nunca devem ser discados (suppression list)"
actions={
can('campaigns.update') && (
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importCsv.mutate(file);
e.target.value = '';
}}
/>
<Button variant="outline" loading={importCsv.isPending} onClick={() => fileInputRef.current?.click()}>
<UploadCloud /> Importar CSV
</Button>
<Button onClick={() => setDialogOpen(true)}>
<Plus /> Bloquear número
</Button>
</div>
)
}
/>
<DataTable
columns={columns}
data={data?.items}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
searchValue={search}
onSearchChange={(v) => {
setSearch(v);
setPage(1);
}}
searchPlaceholder="Buscar por telefone..."
page={data?.page ?? page}
pageSize={data?.pageSize ?? 25}
total={data?.total ?? 0}
onPageChange={setPage}
emptyMessage="Nenhum número bloqueado."
/>
<AddDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}
export default function SuppressionPage() {
return (
<RequirePermission permission="campaigns.view">
<SuppressionContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import * as React from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Play, Pause, Square, Users, FileUp } from 'lucide-react';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { campaignsService } from '@/services/campaigns';
import { dashboardService } from '@/services/dashboard';
import { errorMessage } from '@/lib/error-message';
import { formatPercent, formatSeconds } from '@/lib/utils';
function MetricCard({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Card>
<CardContent className="pt-5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold">{value}</p>
</CardContent>
</Card>
);
}
function CampaignDetailContent({ id }: { id: string }) {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [cpsDraft, setCpsDraft] = React.useState<number | null>(null);
const { data: campaign, isLoading } = useQuery({
queryKey: ['campaign', id],
queryFn: () => campaignsService.get(id),
});
const { data: live } = useQuery({
queryKey: ['campaign-dashboard', id],
queryFn: () => dashboardService.campaign(id),
refetchInterval: 5_000,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['campaign', id] });
queryClient.invalidateQueries({ queryKey: ['campaign-dashboard', id] });
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
};
const onErr = (err: unknown) =>
toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' });
const start = useMutation({
mutationFn: () => campaignsService.start(id),
onSuccess: () => {
invalidate();
toast({ title: 'Campanha iniciada', variant: 'success' });
},
onError: onErr,
});
const pause = useMutation({
mutationFn: () => campaignsService.pause(id),
onSuccess: () => {
invalidate();
toast({ title: 'Campanha pausada', variant: 'success' });
},
onError: onErr,
});
const stop = useMutation({
mutationFn: () => campaignsService.stop(id),
onSuccess: () => {
invalidate();
toast({ title: 'Campanha parada', variant: 'success' });
},
onError: onErr,
});
const updateCps = useMutation({
mutationFn: (maxCps: number) => campaignsService.update(id, { maxCps }),
onSuccess: () => {
invalidate();
setCpsDraft(null);
toast({ title: 'CPS atualizado', variant: 'success' });
},
onError: onErr,
});
if (isLoading || !campaign) {
return <Skeleton className="h-64 w-full" />;
}
return (
<>
<PageHeader
title={campaign.name}
description={`Status: ${campaign.status}`}
actions={
<div className="flex gap-2">
{can('campaigns.start') &&
['DRAFT', 'READY', 'PAUSED', 'STOPPED'].includes(campaign.status) && (
<Button loading={start.isPending} onClick={() => start.mutate()}>
<Play /> Iniciar
</Button>
)}
{can('campaigns.pause') && campaign.status === 'RUNNING' && (
<Button variant="outline" loading={pause.isPending} onClick={() => pause.mutate()}>
<Pause /> Pausar
</Button>
)}
{can('campaigns.stop') && ['RUNNING', 'PAUSED'].includes(campaign.status) && (
<Button variant="destructive" loading={stop.isPending} onClick={() => stop.mutate()}>
<Square /> Parar
</Button>
)}
</div>
}
/>
<div className="mb-6 flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href={`/leads?campaignId=${id}`}>
<Users /> Ver leads
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/importacoes?campaignId=${id}`}>
<FileUp /> Importar leads
</Link>
</Button>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<MetricCard label="CPS atual" value={live?.cpsAtual ?? 0} />
<MetricCard label="Discando" value={live?.dialing ?? 0} />
<MetricCard label="Tocando" value={live?.ringing ?? 0} />
<MetricCard label="Na fila (agente)" value={live?.queued ?? 0} />
<MetricCard label="Conectadas" value={live?.connected ?? 0} />
<MetricCard label="Leads restantes" value={live?.leadsRemaining ?? 0} />
<MetricCard label="Leads processados" value={live?.leadsProcessed ?? 0} />
<MetricCard
label="Fator de pacing"
value={live?.pacingFactor !== null && live?.pacingFactor !== undefined ? live.pacingFactor.toFixed(2) : '—'}
/>
<MetricCard label="Prob. de atendimento" value={formatPercent(live?.answerProbability)} />
<MetricCard label="Taxa de abandono" value={formatPercent(live?.abandonRate)} />
<MetricCard label="TMA médio" value={formatSeconds(live?.avgTalkTimeSeconds)} />
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle>Configuração</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxCps">CPS máximo</Label>
<Input
id="maxCps"
type="number"
min={1}
className="w-32"
value={cpsDraft ?? campaign.maxCps}
onChange={(e) => setCpsDraft(Number(e.target.value))}
/>
</div>
{can('campaigns.update') && (
<Button
variant="outline"
disabled={cpsDraft === null || cpsDraft === campaign.maxCps}
loading={updateCps.isPending}
onClick={() => cpsDraft !== null && updateCps.mutate(cpsDraft)}
>
Salvar CPS
</Button>
)}
<div>
<p className="text-xs text-muted-foreground">Concorrência máxima</p>
<p className="text-sm font-medium">{campaign.maxConcurrentCalls}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Janela de discagem</p>
<p className="text-sm font-medium">
{campaign.startTime}{campaign.endTime} ({campaign.timezone})
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Máx. tentativas</p>
<p className="text-sm font-medium">{campaign.maxAttempts}</p>
</div>
<div>
<Badge variant={campaign.amdEnabled ? 'success' : 'secondary'}>
AMD {campaign.amdEnabled ? 'ativo' : 'inativo'}
</Badge>
</div>
</CardContent>
</Card>
</>
);
}
export default function CampaignDetailPage() {
const params = useParams<{ id: string }>();
return (
<RequirePermission permission="campaigns.view">
<CampaignDetailContent id={params.id} />
</RequirePermission>
);
}

View File

@@ -0,0 +1,324 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Play, Pause, Square, Trash2, ArrowRightCircle } 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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { campaignsService, type CampaignInput } from '@/services/campaigns';
import { queuesService } from '@/services/queues';
import { trunksService } from '@/services/trunks';
import type { Campaign, CampaignStatus } from '@/types';
import { errorMessage } from '@/lib/error-message';
const STATUS_VARIANT: Record<CampaignStatus, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
DRAFT: 'outline',
READY: 'secondary',
RUNNING: 'success',
PAUSED: 'warning',
DRAINING: 'warning',
STOPPED: 'secondary',
COMPLETED: 'secondary',
};
const EMPTY_FORM: CampaignInput = {
name: '',
queueId: '',
trunkId: '',
maxCps: 2,
maxConcurrentCalls: 10,
};
function CreateCampaignDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [form, setForm] = React.useState<CampaignInput>(EMPTY_FORM);
const { data: queues } = useQuery({ queryKey: ['queues'], queryFn: queuesService.list, enabled: open });
const { data: trunks } = useQuery({ queryKey: ['trunks'], queryFn: trunksService.list, enabled: open });
React.useEffect(() => {
if (open) setForm(EMPTY_FORM);
}, [open]);
const mutation = useMutation({
mutationFn: () => campaignsService.create(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
toast({ title: 'Campanha criada', variant: 'success' });
onOpenChange(false);
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Nova campanha</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
<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="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Fila</Label>
<Select value={form.queueId} onValueChange={(v) => setForm((f) => ({ ...f, queueId: v }))}>
<SelectTrigger>
<SelectValue placeholder="Selecione..." />
</SelectTrigger>
<SelectContent>
{(queues ?? []).map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Tronco</Label>
<Select value={form.trunkId} onValueChange={(v) => setForm((f) => ({ ...f, trunkId: v }))}>
<SelectTrigger>
<SelectValue placeholder="Selecione..." />
</SelectTrigger>
<SelectContent>
{(trunks ?? []).map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxCps">CPS máximo</Label>
<Input
id="maxCps"
type="number"
min={1}
required
value={form.maxCps}
onChange={(e) => setForm((f) => ({ ...f, maxCps: Number(e.target.value) }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxConcurrentCalls">Concorrência máx.</Label>
<Input
id="maxConcurrentCalls"
type="number"
min={1}
required
value={form.maxConcurrentCalls}
onChange={(e) =>
setForm((f) => ({ ...f, maxConcurrentCalls: Number(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>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Criar campanha
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function CampaignsContent() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = React.useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['campaigns'],
queryFn: campaignsService.list,
refetchInterval: 15_000,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['campaigns'] });
const onErr = (err: unknown) =>
toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' });
const start = useMutation({
mutationFn: (id: string) => campaignsService.start(id),
onSuccess: () => {
invalidate();
toast({ title: 'Campanha iniciada', variant: 'success' });
},
onError: onErr,
});
const pause = useMutation({
mutationFn: (id: string) => campaignsService.pause(id),
onSuccess: () => {
invalidate();
toast({ title: 'Campanha pausada', variant: 'success' });
},
onError: onErr,
});
const stop = useMutation({
mutationFn: (id: string) => campaignsService.stop(id),
onSuccess: () => {
invalidate();
toast({ title: 'Campanha parada', variant: 'success' });
},
onError: onErr,
});
const remove = useMutation({
mutationFn: (id: string) => campaignsService.remove(id),
onSuccess: () => {
invalidate();
toast({ title: 'Campanha removida', variant: 'success' });
},
onError: onErr,
});
const columns: DataTableColumn<Campaign>[] = [
{
key: 'name',
header: 'Nome',
render: (r) => (
<Link href={`/campanhas/${r.id}`} className="font-medium hover:underline">
{r.name}
</Link>
),
},
{
key: 'status',
header: 'Status',
render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{r.status}</Badge>,
},
{ key: 'maxCps', header: 'CPS', render: (r) => r.maxCps },
{ key: 'maxConcurrentCalls', header: 'Concorrência', render: (r) => r.maxConcurrentCalls },
{ key: 'schedule', header: 'Janela', render: (r) => `${r.startTime}${r.endTime}` },
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
{can('campaigns.start') && (r.status === 'DRAFT' || r.status === 'READY' || r.status === 'PAUSED' || r.status === 'STOPPED') && (
<Button size="icon" variant="ghost" onClick={() => start.mutate(r.id)} title="Iniciar">
<Play className="size-4" />
</Button>
)}
{can('campaigns.pause') && r.status === 'RUNNING' && (
<Button size="icon" variant="ghost" onClick={() => pause.mutate(r.id)} title="Pausar">
<Pause className="size-4" />
</Button>
)}
{can('campaigns.stop') && (r.status === 'RUNNING' || r.status === 'PAUSED') && (
<Button size="icon" variant="ghost" onClick={() => stop.mutate(r.id)} title="Parar">
<Square className="size-4" />
</Button>
)}
<Button size="icon" variant="ghost" asChild title="Detalhes">
<Link href={`/campanhas/${r.id}`}>
<ArrowRightCircle className="size-4" />
</Link>
</Button>
{can('campaigns.delete') && r.status === 'DRAFT' && (
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Remover a campanha ${r.name}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
),
},
];
return (
<>
<PageHeader
title="Campanhas"
description="Campanhas do discador preditivo"
actions={
can('campaigns.create') && (
<Button onClick={() => setDialogOpen(true)}>
<Plus /> Nova campanha
</Button>
)
}
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhuma campanha cadastrada."
/>
<CreateCampaignDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}
export default function CampaignsPage() {
return (
<RequirePermission permission="campaigns.view">
<CampaignsContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,205 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Save, AlertTriangle } from 'lucide-react';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { complianceService, type UpdateComplianceInput } from '@/services/compliance';
import { errorMessage } from '@/lib/error-message';
function SettingsCard() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = React.useState<UpdateComplianceInput>({});
const { data: settings, isLoading } = useQuery({
queryKey: ['compliance-settings'],
queryFn: complianceService.getSettings,
});
React.useEffect(() => {
if (settings) {
setForm({
shortCallThresholdSeconds: settings.shortCallThresholdSeconds,
maxAttemptsPerNumberPerDay: settings.maxAttemptsPerNumberPerDay,
maxAttemptsPerNumberPerMonth: settings.maxAttemptsPerNumberPerMonth,
highVolumeMonthlyThreshold: settings.highVolumeMonthlyThreshold,
});
}
}, [settings]);
const mutation = useMutation({
mutationFn: () => complianceService.updateSettings(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['compliance-settings'] });
queryClient.invalidateQueries({ queryKey: ['compliance-indicators'] });
toast({ title: 'Configuração salva', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
if (isLoading || !settings) return null;
return (
<Card>
<CardHeader>
<CardTitle>Parâmetros de Compliance</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="shortCall">Limiar de chamada curta (s)</Label>
<Input
id="shortCall"
type="number"
disabled={!can('settings.manage')}
value={form.shortCallThresholdSeconds ?? ''}
onChange={(e) =>
setForm((f) => ({ ...f, shortCallThresholdSeconds: Number(e.target.value) }))
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxDay">Máx. tentativas por número/dia</Label>
<Input
id="maxDay"
type="number"
disabled={!can('settings.manage')}
value={form.maxAttemptsPerNumberPerDay ?? ''}
onChange={(e) =>
setForm((f) => ({ ...f, maxAttemptsPerNumberPerDay: Number(e.target.value) }))
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxMonth">Máx. tentativas por número/mês</Label>
<Input
id="maxMonth"
type="number"
disabled={!can('settings.manage')}
value={form.maxAttemptsPerNumberPerMonth ?? ''}
onChange={(e) =>
setForm((f) => ({ ...f, maxAttemptsPerNumberPerMonth: Number(e.target.value) }))
}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="highVolume">Limiar de alto volume mensal</Label>
<Input
id="highVolume"
type="number"
disabled={!can('settings.manage')}
value={form.highVolumeMonthlyThreshold ?? ''}
onChange={(e) =>
setForm((f) => ({ ...f, highVolumeMonthlyThreshold: Number(e.target.value) }))
}
/>
</div>
{can('settings.manage') && (
<div className="sm:col-span-2">
<Button type="submit" loading={mutation.isPending}>
<Save /> Salvar
</Button>
</div>
)}
</form>
</CardContent>
</Card>
);
}
function IndicatorsCard() {
const { data } = useQuery({
queryKey: ['compliance-indicators'],
queryFn: complianceService.indicators,
refetchInterval: 30_000,
});
if (!data) return null;
return (
<Card>
<CardHeader>
<CardTitle>Indicadores (hoje / mês)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p className="text-xs text-muted-foreground">Chamadas hoje</p>
<p className="text-xl font-semibold">{data.totalCallsToday}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Atendidas hoje</p>
<p className="text-xl font-semibold">{data.answeredCallsToday}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Chamadas curtas hoje</p>
<p className="text-xl font-semibold">{data.shortCallsToday}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Abandonadas hoje</p>
<p className="text-xl font-semibold">{data.abandonedCallsToday}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Chamadas no mês</p>
<p className="text-xl font-semibold">{data.totalCallsMonth}</p>
</div>
</div>
{data.alerts.length > 0 && (
<div className="flex flex-col gap-2">
{data.alerts.map((a, i) => (
<div key={i} className="flex items-center gap-2 rounded-md border border-warning/40 bg-warning/10 p-3 text-sm">
<AlertTriangle className="size-4 text-warning" />
{a}
</div>
))}
</div>
)}
{data.numbersOverDailyLimit.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium">Números acima do limite diário</p>
<div className="flex flex-wrap gap-1">
{data.numbersOverDailyLimit.map((n) => (
<Badge key={n.phoneNormalized} variant="destructive">
{n.phoneNormalized} ({n.attempts})
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
export default function CompliancePage() {
return (
<RequirePermission permission="settings.manage">
<PageHeader
title="Compliance"
description="Parâmetros configuráveis e indicadores de compliance de discagem"
/>
<div className="flex flex-col gap-4">
<IndicatorsCard />
<SettingsCard />
</div>
</RequirePermission>
);
}

View File

@@ -0,0 +1,360 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil, UploadCloud, RotateCcw } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { dialplanService, type DialplanEntryInput } from '@/services/dialplan';
import type { DialplanEntry, DialplanVersion } from '@/types';
import { errorMessage } from '@/lib/error-message';
import { formatDateTime } from '@/lib/utils';
const EMPTY_FORM: DialplanEntryInput = {
context: '',
exten: '',
priority: 1,
application: '',
argument: '',
enabled: true,
};
function EntryFormDialog({
open,
onOpenChange,
entry,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
entry: DialplanEntry | null;
}) {
const isEdit = Boolean(entry);
const queryClient = useQueryClient();
const { toast } = useToast();
const [form, setForm] = React.useState<DialplanEntryInput>(EMPTY_FORM);
React.useEffect(() => {
if (open) {
setForm(
entry
? {
context: entry.context,
exten: entry.exten,
priority: entry.priority,
application: entry.application,
argument: entry.argument ?? '',
enabled: entry.enabled,
}
: EMPTY_FORM,
);
}
}, [open, entry]);
const mutation = useMutation({
mutationFn: () =>
isEdit && entry
? dialplanService.update(entry.id, form)
: dialplanService.create(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dialplan-entries'] });
toast({ title: 'Entrada salva', 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 entrada' : 'Nova entrada de dialplan'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="context">Contexto</Label>
<Input
id="context"
required
value={form.context}
onChange={(e) => setForm((f) => ({ ...f, context: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="exten">Extensão</Label>
<Input
id="exten"
required
value={form.exten}
onChange={(e) => setForm((f) => ({ ...f, exten: e.target.value }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="priority">Prioridade</Label>
<Input
id="priority"
type="number"
min={1}
required
value={form.priority}
onChange={(e) => setForm((f) => ({ ...f, priority: Number(e.target.value) }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="application">Aplicação</Label>
<Input
id="application"
required
placeholder="Dial, Answer, Hangup..."
value={form.application}
onChange={(e) => setForm((f) => ({ ...f, application: e.target.value }))}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="argument">Argumento</Label>
<Input
id="argument"
value={form.argument ?? ''}
onChange={(e) => setForm((f) => ({ ...f, argument: e.target.value }))}
/>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function EntriesTab() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editing, setEditing] = React.useState<DialplanEntry | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['dialplan-entries'],
queryFn: () => dialplanService.list(),
});
const remove = useMutation({
mutationFn: (id: string) => dialplanService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dialplan-entries'] });
toast({ title: 'Entrada removida', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const publish = useMutation({
mutationFn: () => dialplanService.publish(),
onSuccess: (version) => {
queryClient.invalidateQueries({ queryKey: ['dialplan-versions'] });
toast({
title: version.status === 'APPLIED' ? 'Dialplan publicado' : 'Falha ao publicar',
description: version.reloadResult ?? undefined,
variant: version.status === 'APPLIED' ? 'success' : 'destructive',
});
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<DialplanEntry>[] = [
{ key: 'context', header: 'Contexto', render: (r) => r.context },
{ key: 'exten', header: 'Extensão', render: (r) => r.exten },
{ key: 'priority', header: 'Prio.', render: (r) => r.priority },
{ key: 'application', header: 'Aplicação', render: (r) => r.application },
{ key: 'argument', header: 'Argumento', render: (r) => r.argument ?? '—' },
{
key: 'enabled',
header: 'Status',
render: (r) => (
<Badge variant={r.enabled ? 'success' : 'secondary'}>
{r.enabled ? 'Ativa' : 'Inativa'}
</Badge>
),
},
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
{can('dialplans.update') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
)}
{can('dialplans.delete') && (
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm('Remover esta entrada de dialplan?')) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
),
},
];
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end gap-2">
{can('dialplans.update') && (
<Button variant="outline" loading={publish.isPending} onClick={() => publish.mutate()}>
<UploadCloud /> Publicar
</Button>
)}
{can('dialplans.create') && (
<Button
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus /> Nova entrada
</Button>
)}
</div>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhuma entrada de dialplan cadastrada."
/>
<EntryFormDialog open={dialogOpen} onOpenChange={setDialogOpen} entry={editing} />
</div>
);
}
function VersionsTab() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['dialplan-versions'],
queryFn: dialplanService.versions,
});
const rollback = useMutation({
mutationFn: (id: string) => dialplanService.rollback(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dialplan-versions'] });
toast({ title: 'Rollback aplicado', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<DialplanVersion>[] = [
{ key: 'createdAt', header: 'Data', render: (r) => formatDateTime(r.createdAt) },
{
key: 'status',
header: 'Status',
render: (r) => (
<Badge
variant={
r.status === 'APPLIED' ? 'success' : r.status === 'FAILED' ? 'destructive' : 'secondary'
}
>
{r.status}
</Badge>
),
},
{ key: 'reloadResult', header: 'Resultado', render: (r) => r.reloadResult ?? '—' },
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) =>
can('dialplans.update') && r.status === 'APPLIED' ? (
<Button size="sm" variant="outline" onClick={() => rollback.mutate(r.id)}>
<RotateCcw className="size-3.5" /> Rollback
</Button>
) : null,
},
];
return (
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhuma versão publicada ainda."
/>
);
}
function DialplanContent() {
return (
<>
<PageHeader title="Dialplan" description="Plano de discagem estruturado e versionado" />
<Tabs defaultValue="entries">
<TabsList>
<TabsTrigger value="entries">Entradas</TabsTrigger>
<TabsTrigger value="versions">Versões</TabsTrigger>
</TabsList>
<TabsContent value="entries">
<EntriesTab />
</TabsContent>
<TabsContent value="versions">
<VersionsTab />
</TabsContent>
</Tabs>
</>
);
}
export default function DialplanPage() {
return (
<RequirePermission permission="dialplans.view">
<DialplanContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil } 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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { dispositionsService, type DispositionInput } from '@/services/dispositions';
import type { Disposition, DispositionAction } from '@/types';
import { errorMessage } from '@/lib/error-message';
const EMPTY_FORM: DispositionInput = { name: '', code: '', action: 'NONE', active: true };
const ACTIONS: DispositionAction[] = ['NONE', 'CALLBACK', 'DO_NOT_CALL'];
const ACTION_LABEL: Record<DispositionAction, string> = {
NONE: 'Nenhuma',
CALLBACK: 'Agendar retorno',
DO_NOT_CALL: 'Não ligar mais',
};
function FormDialog({
open,
onOpenChange,
item,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
item: Disposition | null;
}) {
const isEdit = Boolean(item);
const queryClient = useQueryClient();
const { toast } = useToast();
const [form, setForm] = React.useState<DispositionInput>(EMPTY_FORM);
React.useEffect(() => {
if (open) {
setForm(
item
? {
name: item.name,
description: item.description ?? '',
action: item.action,
active: item.active,
}
: EMPTY_FORM,
);
}
}, [open, item]);
const mutation = useMutation({
mutationFn: () =>
isEdit && item
? dispositionsService.update(item.id, form)
: dispositionsService.create(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dispositions'] });
toast({ title: 'Disposição salva', 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 disposição' : 'Nova disposição'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-3">
<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>
{!isEdit && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="code">Código</Label>
<Input
id="code"
required
value={form.code ?? ''}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
/>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label>Ação automática</Label>
<Select
value={form.action}
onValueChange={(v) => setForm((f) => ({ ...f, action: v as DispositionAction }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIONS.map((a) => (
<SelectItem key={a} value={a}>
{ACTION_LABEL[a]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function DispositionsContent() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editing, setEditing] = React.useState<Disposition | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['dispositions'],
queryFn: dispositionsService.list,
});
const remove = useMutation({
mutationFn: (id: string) => dispositionsService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dispositions'] });
toast({ title: 'Disposição removida', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<Disposition>[] = [
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'code', header: 'Código', render: (r) => r.code },
{ key: 'action', header: 'Ação', render: (r) => ACTION_LABEL[r.action] },
{
key: 'active',
header: 'Status',
render: (r) => (
<Badge variant={r.active ? 'success' : 'secondary'}>{r.active ? 'Ativa' : 'Inativa'}</Badge>
),
},
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
{can('settings.manage') && (
<>
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Remover a disposição ${r.name}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
</>
)}
</div>
),
},
];
return (
<>
<PageHeader
title="Disposições"
description="Códigos de disposição de chamada usados pelos agentes"
actions={
can('settings.manage') && (
<Button
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus /> Nova disposição
</Button>
)
}
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhuma disposição cadastrada."
/>
<FormDialog open={dialogOpen} onOpenChange={setDialogOpen} item={editing} />
</>
);
}
export default function DispositionsPage() {
return (
<RequirePermission permission="settings.manage">
<DispositionsContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,400 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil, Users, X } 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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { queuesService, type QueueInput } from '@/services/queues';
import { agentsService } from '@/services/agents';
import type { Queue, QueueStrategy } from '@/types';
import { errorMessage } from '@/lib/error-message';
const STRATEGIES: QueueStrategy[] = [
'ringall',
'leastrecent',
'fewestcalls',
'random',
'rrmemory',
'rrordered',
'linear',
'wrandom',
];
const EMPTY_FORM: QueueInput = { name: '', number: '', strategy: 'ringall', enabled: true };
function QueueFormDialog({
open,
onOpenChange,
queue,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
queue: Queue | null;
}) {
const isEdit = Boolean(queue);
const queryClient = useQueryClient();
const { toast } = useToast();
const [form, setForm] = React.useState<QueueInput>(EMPTY_FORM);
React.useEffect(() => {
if (open) {
setForm(
queue
? {
strategy: queue.strategy,
timeout: queue.timeout,
wrapUpTime: queue.wrapUpTime,
maxLen: queue.maxLen,
enabled: queue.enabled,
}
: EMPTY_FORM,
);
}
}, [open, queue]);
const mutation = useMutation({
mutationFn: () =>
isEdit && queue ? queuesService.update(queue.id, form) : queuesService.create(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queues'] });
toast({ title: 'Fila salva', 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 fila' : 'Nova fila'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
{!isEdit && (
<div className="grid grid-cols-2 gap-3">
<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="number">Número</Label>
<Input
id="number"
required
value={form.number ?? ''}
onChange={(e) => setForm((f) => ({ ...f, number: e.target.value }))}
/>
</div>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label>Estratégia</Label>
<Select
value={form.strategy}
onValueChange={(v) => setForm((f) => ({ ...f, strategy: v as QueueStrategy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STRATEGIES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="timeout">Timeout de toque (s)</Label>
<Input
id="timeout"
type="number"
value={form.timeout ?? 15}
onChange={(e) => setForm((f) => ({ ...f, timeout: Number(e.target.value) }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wrapUpTime">Wrap-up (s)</Label>
<Input
id="wrapUpTime"
type="number"
value={form.wrapUpTime ?? 0}
onChange={(e) => setForm((f) => ({ ...f, wrapUpTime: Number(e.target.value) }))}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function MembersDialog({
open,
onOpenChange,
queue,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
queue: Queue | null;
}) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [selectedAgentId, setSelectedAgentId] = React.useState('');
const { data: agents } = useQuery({
queryKey: ['agents'],
queryFn: agentsService.list,
enabled: open,
});
const { data: fullQueue, refetch } = useQuery({
queryKey: ['queue-detail', queue?.id],
queryFn: () => queuesService.get(queue!.id),
enabled: open && Boolean(queue),
});
const addMember = useMutation({
mutationFn: () => queuesService.addMember(queue!.id, selectedAgentId),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ['queues'] });
setSelectedAgentId('');
toast({ title: 'Agente associado', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const removeMember = useMutation({
mutationFn: (agentId: string) => queuesService.removeMember(queue!.id, agentId),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ['queues'] });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const memberIds = new Set((fullQueue?.members ?? []).map((m) => m.agentId));
const availableAgents = (agents ?? []).filter((a) => !memberIds.has(a.id));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agentes da fila {queue?.name}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Selecione um agente..." />
</SelectTrigger>
<SelectContent>
{availableAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.code} {a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedAgentId}
loading={addMember.isPending}
onClick={() => addMember.mutate()}
>
<Plus /> Adicionar
</Button>
</div>
<ul className="flex flex-col gap-1.5">
{(fullQueue?.members ?? []).map((m) => (
<li
key={m.agentId}
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
>
<span>
{m.agent?.name ?? m.agentId}
{m.penalty ? ` (penalidade ${m.penalty})` : ''}
</span>
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => removeMember.mutate(m.agentId)}
>
<X className="size-3.5" />
</Button>
</li>
))}
{(fullQueue?.members ?? []).length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
Nenhum agente associado.
</p>
)}
</ul>
</div>
</DialogContent>
</Dialog>
);
}
function QueuesContent() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [membersOpen, setMembersOpen] = React.useState(false);
const [editing, setEditing] = React.useState<Queue | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['queues'],
queryFn: queuesService.list,
});
const remove = useMutation({
mutationFn: (id: string) => queuesService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queues'] });
toast({ title: 'Fila removida', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<Queue>[] = [
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'number', header: 'Número', render: (r) => r.number },
{ key: 'strategy', header: 'Estratégia', render: (r) => r.strategy },
{
key: 'enabled',
header: 'Status',
render: (r) => (
<Badge variant={r.enabled ? 'success' : 'secondary'}>
{r.enabled ? 'Ativa' : 'Inativa'}
</Badge>
),
},
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
{can('queues.update') && (
<>
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setMembersOpen(true);
}}
>
<Users className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
</>
)}
{can('queues.delete') && (
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Remover a fila ${r.name}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
),
},
];
return (
<>
<PageHeader
title="Filas"
description="Filas de atendimento (ACD)"
actions={
can('queues.create') && (
<Button
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus /> Nova fila
</Button>
)
}
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhuma fila cadastrada."
/>
<QueueFormDialog open={dialogOpen} onOpenChange={setDialogOpen} queue={editing} />
<MembersDialog open={membersOpen} onOpenChange={setMembersOpen} queue={editing} />
</>
);
}
export default function QueuesPage() {
return (
<RequirePermission permission="queues.view">
<QueuesContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import * as React from 'react';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { UploadCloud, Download } from 'lucide-react';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { CampaignSelect } from '@/components/campaign-select';
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { leadsService } from '@/services/leads';
import type { LeadImport } from '@/types';
import { errorMessage } from '@/lib/error-message';
import { formatDateTime } from '@/lib/utils';
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function ImportsContent() {
const params = useSearchParams();
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [campaignId, setCampaignId] = React.useState(params.get('campaignId') ?? '');
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['lead-imports', campaignId],
queryFn: () => leadsService.imports(campaignId),
enabled: Boolean(campaignId),
});
const upload = useMutation({
mutationFn: (file: File) => leadsService.importCsv(campaignId, file),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['lead-imports', campaignId] });
queryClient.invalidateQueries({ queryKey: ['leads'] });
toast({
title: 'Importação concluída',
description: `${result.valid} válidos, ${result.invalid} inválidos, ${result.duplicate} duplicados de ${result.total} linhas.`,
variant: 'success',
});
},
onError: (err) => toast({ title: 'Erro na importação', description: errorMessage(err), variant: 'destructive' }),
});
const downloadRejected = useMutation({
mutationFn: (importId: string) => leadsService.downloadRejected(campaignId, importId),
onSuccess: (blob) => downloadBlob(blob, 'rejeitados.csv'),
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<LeadImport>[] = [
{ key: 'filename', header: 'Arquivo', render: (r) => r.filename },
{
key: 'status',
header: 'Status',
render: (r) => <Badge variant="secondary">{r.status}</Badge>,
},
{ key: 'totalRows', header: 'Total', render: (r) => r.totalRows },
{ key: 'validRows', header: 'Válidos', render: (r) => r.validRows },
{ key: 'invalidRows', header: 'Inválidos', render: (r) => r.invalidRows },
{ key: 'duplicateRows', header: 'Duplicados', render: (r) => r.duplicateRows },
{ key: 'createdAt', header: 'Data', render: (r) => formatDateTime(r.createdAt) },
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) =>
r.invalidRows > 0 ? (
<Button size="sm" variant="outline" onClick={() => downloadRejected.mutate(r.id)}>
<Download className="size-3.5" /> Rejeitados
</Button>
) : null,
},
];
return (
<>
<PageHeader title="Importações" description="Importação de leads via CSV por campanha" />
<div className="mb-4 flex flex-wrap items-center gap-3">
<CampaignSelect value={campaignId} onChange={setCampaignId} />
{campaignId && can('campaigns.update') && (
<>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload.mutate(file);
e.target.value = '';
}}
/>
<Button loading={upload.isPending} onClick={() => fileInputRef.current?.click()}>
<UploadCloud /> Importar CSV
</Button>
</>
)}
</div>
{!campaignId ? (
<p className="py-16 text-center text-sm text-muted-foreground">
Selecione uma campanha para ver o histórico de importações.
</p>
) : (
<Card>
<CardContent className="pt-5">
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhuma importação realizada ainda."
/>
</CardContent>
</Card>
)}
</>
);
}
export default function ImportsPage() {
return (
<RequirePermission permission="campaigns.view">
<Suspense fallback={null}>
<ImportsContent />
</Suspense>
</RequirePermission>
);
}

View File

@@ -0,0 +1,5 @@
import { AppShell } from '@/components/layout/app-shell';
export default function AppGroupLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,151 @@
'use client';
import * as React from 'react';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { CampaignSelect } from '@/components/campaign-select';
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { leadsService } from '@/services/leads';
import type { Lead, LeadStatus } from '@/types';
import { useDebounce } from '@/hooks/use-debounce';
import { formatDateTime } from '@/lib/utils';
const STATUSES: LeadStatus[] = [
'NEW',
'READY',
'RESERVED',
'DIALING',
'RINGING',
'BUSY',
'NO_ANSWER',
'FAILED',
'COMPLETED',
'MAX_ATTEMPTS',
'DO_NOT_CALL',
'INVALID',
];
const STATUS_VARIANT: Record<string, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
COMPLETED: 'success',
READY: 'secondary',
NEW: 'outline',
DIALING: 'warning',
RINGING: 'warning',
BUSY: 'warning',
NO_ANSWER: 'warning',
FAILED: 'destructive',
MAX_ATTEMPTS: 'destructive',
DO_NOT_CALL: 'destructive',
INVALID: 'destructive',
RESERVED: 'secondary',
};
function LeadsContent() {
const params = useSearchParams();
const [campaignId, setCampaignId] = React.useState(params.get('campaignId') ?? '');
const [search, setSearch] = React.useState('');
const debouncedSearch = useDebounce(search);
const [status, setStatus] = React.useState<string>('all');
const [page, setPage] = React.useState(1);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['leads', campaignId, debouncedSearch, status, page],
queryFn: () =>
leadsService.query(campaignId, {
search: debouncedSearch || undefined,
status: status === 'all' ? undefined : (status as LeadStatus),
page,
pageSize: 25,
}),
enabled: Boolean(campaignId),
});
const columns: DataTableColumn<Lead>[] = [
{ key: 'name', header: 'Nome', render: (r) => r.name ?? '—' },
{ key: 'phone', header: 'Telefone', render: (r) => r.phone },
{
key: 'status',
header: 'Status',
render: (r) => <Badge variant={STATUS_VARIANT[r.status] ?? 'outline'}>{r.status}</Badge>,
},
{ key: 'attemptCount', header: 'Tentativas', render: (r) => r.attemptCount },
{ key: 'lastAttemptAt', header: 'Última tentativa', render: (r) => formatDateTime(r.lastAttemptAt) },
{ key: 'nextAttemptAt', header: 'Próxima tentativa', render: (r) => formatDateTime(r.nextAttemptAt) },
{ key: 'lastResult', header: 'Último resultado', render: (r) => r.lastResult ?? '—' },
];
return (
<>
<PageHeader title="Leads" description="Leads de uma campanha do discador" />
<div className="mb-4 flex flex-wrap items-center gap-3">
<CampaignSelect
value={campaignId}
onChange={(v) => {
setCampaignId(v);
setPage(1);
}}
/>
<Select value={status} onValueChange={(v) => { setStatus(v); setPage(1); }}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os status</SelectItem>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!campaignId ? (
<p className="py-16 text-center text-sm text-muted-foreground">
Selecione uma campanha para ver os leads.
</p>
) : (
<DataTable
columns={columns}
data={data?.items}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
searchValue={search}
onSearchChange={(v) => {
setSearch(v);
setPage(1);
}}
searchPlaceholder="Buscar por nome ou telefone..."
page={data?.page ?? page}
pageSize={data?.pageSize ?? 25}
total={data?.total ?? 0}
onPageChange={setPage}
emptyMessage="Nenhum lead encontrado para os filtros selecionados."
/>
)}
</>
);
}
export default function LeadsPage() {
return (
<RequirePermission permission="campaigns.view">
<Suspense fallback={null}>
<LeadsContent />
</Suspense>
</RequirePermission>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
import { Badge } from '@/components/ui/badge';
import { monitoringService } from '@/services/monitoring';
import type { AgentMonitor, AgentState } from '@/types';
import { formatDateTime } from '@/lib/utils';
const STATE_VARIANT: Record<AgentState, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
AVAILABLE: 'success',
IN_CALL: 'warning',
RINGING: 'warning',
WRAP_UP: 'warning',
PAUSED: 'destructive',
LOGGED_IN: 'secondary',
LOGGED_OUT: 'outline',
OFFLINE: 'outline',
};
const STATE_LABEL: Record<AgentState, string> = {
AVAILABLE: 'Disponível',
IN_CALL: 'Em chamada',
RINGING: 'Chamando',
WRAP_UP: 'Pós-atendimento',
PAUSED: 'Pausado',
LOGGED_IN: 'Logado',
LOGGED_OUT: 'Deslogado',
OFFLINE: 'Offline',
};
function Content() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['monitoring-agents'],
queryFn: monitoringService.agents,
refetchInterval: 5_000,
});
const columns: DataTableColumn<AgentMonitor>[] = [
{ key: 'code', header: 'Código', render: (r) => r.code },
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'currentExtension', header: 'Ramal', render: (r) => r.currentExtension ?? '—' },
{
key: 'queues',
header: 'Filas',
render: (r) => (
<div className="flex flex-wrap gap-1">
{r.queues.map((q) => (
<Badge key={q} variant="secondary">
{q}
</Badge>
))}
</div>
),
},
{
key: 'state',
header: 'Estado',
render: (r) => <Badge variant={STATE_VARIANT[r.state]}>{STATE_LABEL[r.state]}</Badge>,
},
{ key: 'stateSince', header: 'Desde', render: (r) => formatDateTime(r.stateSince) },
];
return (
<>
<PageHeader
title="Monitoramento de Agentes"
description="Estado corrente de cada agente (atualiza a cada 5s)"
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhum agente ativo."
/>
</>
);
}
export default function MonitoringAgentsPage() {
return (
<RequirePermission permission="monitoring.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import Link from 'next/link';
import { useQueries, useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { campaignsService } from '@/services/campaigns';
import { dashboardService } from '@/services/dashboard';
import { formatPercent, formatSeconds } from '@/lib/utils';
const ACTIVE_STATUSES = new Set(['RUNNING', 'PAUSED', 'DRAINING']);
function Content() {
const { data: campaigns, isLoading } = useQuery({
queryKey: ['campaigns'],
queryFn: campaignsService.list,
refetchInterval: 15_000,
});
const active = (campaigns ?? []).filter((c) => ACTIVE_STATUSES.has(c.status));
const liveQueries = useQueries({
queries: active.map((c) => ({
queryKey: ['campaign-dashboard', c.id],
queryFn: () => dashboardService.campaign(c.id),
refetchInterval: 5_000,
})),
});
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 w-full" />
))}
</div>
);
}
if (active.length === 0) {
return (
<p className="py-16 text-center text-sm text-muted-foreground">
Nenhuma campanha em execução, pausada ou drenando no momento.
</p>
);
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{active.map((campaign, idx) => {
const live = liveQueries[idx]?.data;
return (
<Card key={campaign.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<Link href={`/campanhas/${campaign.id}`} className="hover:underline">
{campaign.name}
</Link>
<Badge variant={campaign.status === 'RUNNING' ? 'success' : 'warning'}>
{campaign.status}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">CPS atual</p>
<p className="font-medium">{live?.cpsAtual ?? '—'} / {campaign.maxCps}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Conectadas</p>
<p className="font-medium">{live?.connected ?? '—'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Leads restantes</p>
<p className="font-medium">{live?.leadsRemaining ?? '—'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Abandono</p>
<p className="font-medium">{formatPercent(live?.abandonRate ?? undefined)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">TMA médio</p>
<p className="font-medium">{formatSeconds(live?.avgTalkTimeSeconds ?? undefined)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Pacing</p>
<p className="font-medium">{live?.pacingFactor?.toFixed(2) ?? '—'}</p>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
export default function MonitoringCampaignsPage() {
return (
<RequirePermission permission="monitoring.view">
<PageHeader title="Monitoramento de Campanhas" description="Campanhas ativas em tempo real" />
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
import { Badge } from '@/components/ui/badge';
import { monitoringService } from '@/services/monitoring';
import type { QueueMonitor } from '@/types';
import { formatSeconds } from '@/lib/utils';
function Content() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['monitoring-queues'],
queryFn: monitoringService.queues,
refetchInterval: 5_000,
});
const columns: DataTableColumn<QueueMonitor>[] = [
{ key: 'name', header: 'Fila', render: (r) => r.name },
{ key: 'strategy', header: 'Estratégia', render: (r) => r.strategy },
{
key: 'callsWaiting',
header: 'Aguardando',
render: (r) => (
<Badge variant={r.callsWaiting > 0 ? 'warning' : 'secondary'}>{r.callsWaiting}</Badge>
),
},
{
key: 'longestWaitSeconds',
header: 'Maior espera',
render: (r) => formatSeconds(r.longestWaitSeconds),
},
{ key: 'agentsLoggedIn', header: 'Agentes logados', render: (r) => r.agentsLoggedIn },
{ key: 'agentsAvailable', header: 'Disponíveis', render: (r) => r.agentsAvailable },
{ key: 'agentsPaused', header: 'Pausados', render: (r) => r.agentsPaused },
];
return (
<>
<PageHeader title="Monitoramento de Filas" description="Estado em tempo real das filas (atualiza a cada 5s)" />
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhuma fila ativa."
/>
</>
);
}
export default function MonitoringQueuesPage() {
return (
<RequirePermission permission="monitoring.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
import { Badge } from '@/components/ui/badge';
import { monitoringService } from '@/services/monitoring';
import type { ExtensionMonitor } from '@/types';
import { formatDateTime } from '@/lib/utils';
const STATUS_VARIANT: Record<string, 'success' | 'secondary' | 'warning' | 'destructive'> = {
online: 'success',
busy: 'warning',
offline: 'destructive',
unknown: 'secondary',
};
const STATUS_LABEL: Record<string, string> = {
online: 'Online',
busy: 'Em uso',
offline: 'Offline',
unknown: 'Desconhecido',
};
function Content() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['monitoring-extensions'],
queryFn: monitoringService.extensions,
refetchInterval: 5_000,
});
const columns: DataTableColumn<ExtensionMonitor>[] = [
{ key: 'number', header: 'Ramal', render: (r) => r.number },
{ key: 'name', header: 'Nome', render: (r) => r.name },
{
key: 'status',
header: 'Status',
render: (r) => (
<Badge variant={STATUS_VARIANT[r.status] ?? 'secondary'}>{STATUS_LABEL[r.status] ?? r.status}</Badge>
),
},
{ key: 'deviceState', header: 'Device State', render: (r) => r.deviceState ?? '—' },
{ key: 'contactStatus', header: 'Contact Status', render: (r) => r.contactStatus ?? '—' },
{ key: 'updatedAt', header: 'Atualizado em', render: (r) => formatDateTime(r.updatedAt) },
];
return (
<>
<PageHeader
title="Monitoramento de Ramais"
description="Estado dos ramais SIP (device state / contact status), atualiza a cada 5s"
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.number}
emptyMessage="Nenhum ramal cadastrado."
/>
</>
);
}
export default function MonitoringExtensionsPage() {
return (
<RequirePermission permission="monitoring.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import {
PhoneCall,
PhoneOutgoing,
Users,
Clock,
BarChart3,
PhoneOff,
} from 'lucide-react';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
Tooltip as RechartsTooltip,
} from 'recharts';
import { dashboardService } from '@/services/dashboard';
import { formatPercent, formatSeconds } from '@/lib/utils';
function StatCard({
label,
value,
icon: Icon,
isLoading,
}: {
label: string;
value: React.ReactNode;
icon: React.ComponentType<{ className?: string }>;
isLoading?: boolean;
}) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-5">
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-5" />
</span>
<div>
<p className="text-xs text-muted-foreground">{label}</p>
{isLoading ? (
<Skeleton className="mt-1 h-6 w-16" />
) : (
<p className="text-xl font-semibold">{value}</p>
)}
</div>
</CardContent>
</Card>
);
}
function DashboardContent() {
const overview = useQuery({
queryKey: ['dashboard', 'overview'],
queryFn: dashboardService.overview,
refetchInterval: 10_000,
});
const callsByHour = useQuery({
queryKey: ['dashboard', 'calls-by-hour'],
queryFn: dashboardService.callsByHour,
refetchInterval: 30_000,
});
const data = overview.data;
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Chamadas hoje"
value={data?.callsToday ?? 0}
icon={PhoneOutgoing}
isLoading={overview.isLoading}
/>
<StatCard
label="Atendidas hoje"
value={data?.answeredToday ?? 0}
icon={PhoneCall}
isLoading={overview.isLoading}
/>
<StatCard
label="Agentes disponíveis"
value={data?.agentsAvailable ?? 0}
icon={Users}
isLoading={overview.isLoading}
/>
<StatCard
label="Aguardando agente"
value={data?.waitingForAgent ?? 0}
icon={Clock}
isLoading={overview.isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="TME médio"
value={formatSeconds(data?.tmeSeconds)}
icon={Clock}
isLoading={overview.isLoading}
/>
<StatCard
label="TMA médio"
value={formatSeconds(data?.tmaSeconds)}
icon={BarChart3}
isLoading={overview.isLoading}
/>
<StatCard
label="Taxa de atendimento"
value={formatPercent(data?.answerRate)}
icon={PhoneCall}
isLoading={overview.isLoading}
/>
<StatCard
label="Taxa de abandono"
value={formatPercent(data?.abandonRate)}
icon={PhoneOff}
isLoading={overview.isLoading}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Chamadas por hora (hoje)</CardTitle>
</CardHeader>
<CardContent>
{callsByHour.isLoading ? (
<Skeleton className="h-64 w-full" />
) : callsByHour.data && callsByHour.data.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={callsByHour.data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="hour" tickFormatter={(h) => `${h}h`} fontSize={12} />
<YAxis fontSize={12} allowDecimals={false} />
<RechartsTooltip
labelFormatter={(h) => `${h}h`}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="total" name="Total" fill="var(--color-primary)" radius={[4, 4, 0, 0]} />
<Bar dataKey="answered" name="Atendidas" fill="var(--color-success)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
Nenhuma chamada registrada hoje ainda.
</p>
)}
</CardContent>
</Card>
</div>
);
}
export default function DashboardPage() {
return (
<RequirePermission permission="dashboard.view">
<PageHeader title="Dashboard" description="Visão geral em tempo real da operação" />
<DashboardContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,254 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil } 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 { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { pauseReasonsService, type PauseReasonInput } from '@/services/pause-reasons';
import type { PauseReason } from '@/types';
import { errorMessage } from '@/lib/error-message';
const EMPTY_FORM: PauseReasonInput = { name: '', code: '', paid: false, active: true };
function FormDialog({
open,
onOpenChange,
item,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
item: PauseReason | null;
}) {
const isEdit = Boolean(item);
const queryClient = useQueryClient();
const { toast } = useToast();
const [form, setForm] = React.useState<PauseReasonInput>(EMPTY_FORM);
React.useEffect(() => {
if (open) {
setForm(
item
? {
name: item.name,
description: item.description ?? '',
maxDurationSeconds: item.maxDurationSeconds ?? undefined,
paid: item.paid,
active: item.active,
}
: EMPTY_FORM,
);
}
}, [open, item]);
const mutation = useMutation({
mutationFn: () =>
isEdit && item
? pauseReasonsService.update(item.id, form)
: pauseReasonsService.create(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pause-reasons'] });
toast({ title: 'Motivo de pausa 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 motivo de pausa' : 'Novo motivo de pausa'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-3">
<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>
{!isEdit && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="code">Código</Label>
<Input
id="code"
required
value={form.code ?? ''}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
/>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxDurationSeconds">Duração máxima (s, opcional)</Label>
<Input
id="maxDurationSeconds"
type="number"
value={form.maxDurationSeconds ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
maxDurationSeconds: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<Label htmlFor="paid">Pausa remunerada</Label>
<Switch
id="paid"
checked={form.paid ?? false}
onCheckedChange={(v) => setForm((f) => ({ ...f, paid: v }))}
/>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function PauseReasonsContent() {
const { can } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editing, setEditing] = React.useState<PauseReason | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['pause-reasons'],
queryFn: pauseReasonsService.list,
});
const remove = useMutation({
mutationFn: (id: string) => pauseReasonsService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pause-reasons'] });
toast({ title: 'Motivo removido', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<PauseReason>[] = [
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'code', header: 'Código', render: (r) => r.code },
{
key: 'maxDurationSeconds',
header: 'Duração máx.',
render: (r) => (r.maxDurationSeconds ? `${r.maxDurationSeconds}s` : '—'),
},
{
key: 'paid',
header: 'Remunerada',
render: (r) => <Badge variant={r.paid ? 'success' : 'secondary'}>{r.paid ? 'Sim' : 'Não'}</Badge>,
},
{
key: 'active',
header: 'Status',
render: (r) => (
<Badge variant={r.active ? 'success' : 'secondary'}>{r.active ? 'Ativo' : 'Inativo'}</Badge>
),
},
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
{can('settings.manage') && (
<>
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Remover o motivo ${r.name}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
</>
)}
</div>
),
},
];
return (
<>
<PageHeader
title="Motivos de Pausa"
description="Motivos disponíveis para pausa dos agentes"
actions={
can('settings.manage') && (
<Button
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus /> Novo motivo
</Button>
)
}
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhum motivo de pausa cadastrado."
/>
<FormDialog open={dialogOpen} onOpenChange={setDialogOpen} item={editing} />
</>
);
}
export default function PauseReasonsPage() {
return (
<RequirePermission permission="settings.manage">
<PauseReasonsContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,239 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil } 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 { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { rolesService } from '@/services/roles';
import type { Role } from '@/types';
import type { Permission } from '@/lib/permissions';
import { errorMessage } from '@/lib/error-message';
function groupPermissions(permissions: Permission[]): Record<string, Permission[]> {
const groups: Record<string, Permission[]> = {};
for (const p of permissions) {
const prefix = p.split('.')[0] ?? p;
(groups[prefix] ??= []).push(p);
}
return groups;
}
function RoleFormDialog({
open,
onOpenChange,
role,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
role: Role | null;
}) {
const isEdit = Boolean(role);
const queryClient = useQueryClient();
const { toast } = useToast();
const [name, setName] = React.useState('');
const [description, setDescription] = React.useState('');
const [permissionKeys, setPermissionKeys] = React.useState<Permission[]>([]);
const { data: catalog } = useQuery({
queryKey: ['permission-catalog'],
queryFn: rolesService.permissionCatalog,
enabled: open,
});
React.useEffect(() => {
if (open) {
setName(role?.name ?? '');
setDescription(role?.description ?? '');
setPermissionKeys((role?.permissions ?? []) as Permission[]);
}
}, [open, role]);
const mutation = useMutation({
mutationFn: () =>
isEdit && role
? rolesService.update(role.id, { description, permissionKeys })
: rolesService.create({ name, description, permissionKeys }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roles'] });
toast({ title: 'Perfil salvo', variant: 'success' });
onOpenChange(false);
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const groups = groupPermissions(catalog ?? []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar perfil' : 'Novo perfil'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="name">Nome</Label>
<Input
id="name"
required
disabled={isEdit}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="description">Descrição</Label>
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label>Permissões</Label>
<div className="grid max-h-80 grid-cols-2 gap-4 overflow-y-auto rounded-md border border-border p-3">
{Object.entries(groups).map(([group, perms]) => (
<div key={group} className="flex flex-col gap-1.5">
<p className="text-xs font-semibold uppercase text-muted-foreground">{group}</p>
{perms.map((p) => (
<label key={p} className="flex items-center gap-2 text-sm">
<Checkbox
checked={permissionKeys.includes(p)}
onCheckedChange={(checked) =>
setPermissionKeys((prev) =>
checked ? [...prev, p] : prev.filter((k) => k !== p),
)
}
/>
{p}
</label>
))}
</div>
))}
</div>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function Content() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editing, setEditing] = React.useState<Role | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['roles'],
queryFn: rolesService.list,
});
const remove = useMutation({
mutationFn: (id: string) => rolesService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roles'] });
toast({ title: 'Perfil removido', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<Role>[] = [
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'description', header: 'Descrição', render: (r) => r.description ?? '—' },
{ key: 'permissions', header: 'Permissões', render: (r) => `${r.permissions.length} permissões` },
{
key: 'isSystem',
header: 'Tipo',
render: (r) => <Badge variant={r.isSystem ? 'secondary' : 'outline'}>{r.isSystem ? 'Sistema' : 'Customizado'}</Badge>,
},
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
{!r.isSystem && (
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Remover o perfil ${r.name}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
),
},
];
return (
<>
<PageHeader
title="Perfis e Permissões"
description="Perfis (roles) e o catálogo de permissões do RBAC"
actions={
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus /> Novo perfil
</Button>
}
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhum perfil cadastrado."
/>
<RoleFormDialog open={dialogOpen} onOpenChange={setDialogOpen} role={editing} />
</>
);
}
export default function RolesPage() {
return (
<RequirePermission permission="roles.manage">
<Content />
</RequirePermission>
);
}

View 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>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { reportsService } from '@/services/reports';
import { agentsService } from '@/services/agents';
import { formatSeconds } from '@/lib/utils';
const STATE_LABEL: Record<string, string> = {
AVAILABLE: 'Disponível',
IN_CALL: 'Em chamada',
RINGING: 'Chamando',
WRAP_UP: 'Pós-atendimento',
PAUSED: 'Pausado',
LOGGED_IN: 'Logado (ocioso)',
OFFLINE: 'Offline',
};
function Content() {
const [agentId, setAgentId] = React.useState('');
const [from, setFrom] = React.useState('');
const [to, setTo] = React.useState('');
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: agentsService.list });
const { data: report, isLoading } = useQuery({
queryKey: ['agent-report', agentId, from, to],
queryFn: () => reportsService.agentReport(agentId, from || undefined, to || undefined),
enabled: Boolean(agentId),
});
return (
<>
<PageHeader title="Relatório de Agentes" description="Tempo por estado e pausas detalhadas" />
<Card className="mb-4">
<CardContent className="grid grid-cols-1 gap-3 pt-5 sm:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>Agente</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger><SelectValue placeholder="Selecione..." /></SelectTrigger>
<SelectContent>
{(agents ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>De</Label>
<Input type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Até</Label>
<Input type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} />
</div>
</CardContent>
</Card>
{!agentId ? (
<p className="py-16 text-center text-sm text-muted-foreground">Selecione um agente.</p>
) : isLoading ? (
<Skeleton className="h-64 w-full" />
) : report ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<Card>
<CardContent className="pt-5">
<p className="text-xs text-muted-foreground">Chamadas atendidas</p>
<p className="mt-1 text-xl font-semibold">{report.callsAnswered}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5">
<p className="text-xs text-muted-foreground">TMA médio</p>
<p className="mt-1 text-xl font-semibold">{formatSeconds(report.tmaSeconds)}</p>
</CardContent>
</Card>
{Object.entries(report.timeByStateSeconds).map(([state, seconds]) => (
<Card key={state}>
<CardContent className="pt-5">
<p className="text-xs text-muted-foreground">{STATE_LABEL[state] ?? state}</p>
<p className="mt-1 text-xl font-semibold">{formatSeconds(seconds)}</p>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Pausas no período</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Motivo</TableHead>
<TableHead>Início</TableHead>
<TableHead>Fim</TableHead>
<TableHead>Duração</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.pauses.map((p, i) => (
<TableRow key={i}>
<TableCell>{p.reason}</TableCell>
<TableCell>{new Date(p.startedAt).toLocaleString('pt-BR')}</TableCell>
<TableCell>{p.endedAt ? new Date(p.endedAt).toLocaleString('pt-BR') : 'Em andamento'}</TableCell>
<TableCell>{formatSeconds(p.durationSeconds)}</TableCell>
</TableRow>
))}
{report.pauses.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="py-6 text-center text-muted-foreground">
Nenhuma pausa no período.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
) : null}
</>
);
}
export default function AgentReportPage() {
return (
<RequirePermission permission="reports.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Download, Search } 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 { Card, CardContent } from '@/components/ui/card';
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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { useAuth } from '@/hooks/use-auth';
import { useToast } from '@/components/ui/toast';
import { reportsService } from '@/services/reports';
import { campaignsService } from '@/services/campaigns';
import { queuesService } from '@/services/queues';
import { agentsService } from '@/services/agents';
import type { DialAttempt, CallState } from '@/types';
import { errorMessage } from '@/lib/error-message';
import { formatDateTime, formatSeconds, formatPercent } from '@/lib/utils';
const STATES: CallState[] = [
'CREATED',
'RESERVED',
'ORIGINATING',
'RINGING',
'ANSWERED',
'QUEUED',
'AGENT_CONNECTED',
'COMPLETED',
'FAILED',
];
function MetricCard({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Card>
<CardContent className="pt-5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold">{value}</p>
</CardContent>
</Card>
);
}
function Content() {
const { can } = useAuth();
const { toast } = useToast();
const [filters, setFilters] = React.useState({
campaignId: 'all',
queueId: 'all',
agentId: 'all',
state: 'all',
phone: '',
from: '',
to: '',
});
const [page, setPage] = React.useState(1);
const { data: campaigns } = useQuery({ queryKey: ['campaigns'], queryFn: campaignsService.list });
const { data: queues } = useQuery({ queryKey: ['queues'], queryFn: queuesService.list });
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: agentsService.list });
const apiQuery = {
campaignId: filters.campaignId === 'all' ? undefined : filters.campaignId,
queueId: filters.queueId === 'all' ? undefined : filters.queueId,
agentId: filters.agentId === 'all' ? undefined : filters.agentId,
state: filters.state === 'all' ? undefined : filters.state,
phone: filters.phone || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
};
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['reports-calls', apiQuery, page],
queryFn: () => reportsService.calls({ ...apiQuery, page, pageSize: 25 }),
});
const { data: metrics } = useQuery({
queryKey: ['reports-metrics', apiQuery.campaignId, apiQuery.from, apiQuery.to],
queryFn: () =>
reportsService.metrics({
campaignId: apiQuery.campaignId,
from: apiQuery.from,
to: apiQuery.to,
}),
});
const exportMutation = useMutation({
mutationFn: () => reportsService.exportCalls(apiQuery),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'relatorio-chamadas.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const columns: DataTableColumn<DialAttempt>[] = [
{ key: 'startedAt', header: 'Início', render: (r) => formatDateTime(r.startedAt) },
{ key: 'calledNumber', header: 'Número', render: (r) => r.calledNumber },
{ key: 'lead', header: 'Lead', render: (r) => r.lead?.name ?? '—' },
{ key: 'campaign', header: 'Campanha', render: (r) => r.campaign?.name ?? '—' },
{ key: 'state', header: 'Estado', render: (r) => <Badge variant="secondary">{r.state}</Badge> },
{ key: 'hangupCause', header: 'Causa', render: (r) => r.hangupCause ?? '—' },
{ key: 'disposition', header: 'Disposição', render: (r) => r.disposition?.name ?? '—' },
];
return (
<>
<PageHeader
title="Relatório de Chamadas"
description="Pesquisa e exportação de tentativas de chamada"
actions={
can('reports.export') && (
<Button variant="outline" loading={exportMutation.isPending} onClick={() => exportMutation.mutate()}>
<Download /> Exportar CSV
</Button>
)
}
/>
<div className="mb-4 grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="TME médio" value={formatSeconds(metrics?.tmeSeconds)} />
<MetricCard label="TMA médio" value={formatSeconds(metrics?.tmaSeconds)} />
<MetricCard label="Taxa de atendimento" value={formatPercent(metrics?.answerRate)} />
<MetricCard label="Taxa de abandono" value={formatPercent(metrics?.abandonRate)} />
</div>
<Card className="mb-4">
<CardContent className="grid grid-cols-2 gap-3 pt-5 sm:grid-cols-3 lg:grid-cols-6">
<div className="flex flex-col gap-1.5">
<Label>Campanha</Label>
<Select value={filters.campaignId} onValueChange={(v) => setFilters((f) => ({ ...f, campaignId: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas</SelectItem>
{(campaigns ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Fila</Label>
<Select value={filters.queueId} onValueChange={(v) => setFilters((f) => ({ ...f, queueId: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas</SelectItem>
{(queues ?? []).map((q) => (
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Agente</Label>
<Select value={filters.agentId} onValueChange={(v) => setFilters((f) => ({ ...f, agentId: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{(agents ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Estado</Label>
<Select value={filters.state} onValueChange={(v) => setFilters((f) => ({ ...f, state: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{STATES.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Telefone</Label>
<Input value={filters.phone} onChange={(e) => setFilters((f) => ({ ...f, phone: e.target.value }))} />
</div>
<div className="flex items-end gap-2">
<Button onClick={() => { setPage(1); refetch(); }}>
<Search className="size-4" /> Buscar
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label>De</Label>
<Input type="datetime-local" value={filters.from} onChange={(e) => setFilters((f) => ({ ...f, from: e.target.value }))} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Até</Label>
<Input type="datetime-local" value={filters.to} onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value }))} />
</div>
</CardContent>
</Card>
<DataTable
columns={columns}
data={data?.items}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
page={data?.page ?? page}
pageSize={data?.pageSize ?? 25}
total={data?.total ?? 0}
onPageChange={setPage}
emptyMessage="Nenhuma chamada encontrada para os filtros selecionados."
/>
</>
);
}
export default function CallsReportPage() {
return (
<RequirePermission permission="reports.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,331 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil, Activity } 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 {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
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 { trunksService, type TrunkInput } from '@/services/trunks';
import type { Trunk } from '@/types';
import { errorMessage } from '@/lib/error-message';
const EMPTY_FORM: TrunkInput = {
name: '',
type: 'IP',
host: '',
port: 5060,
maxCps: 5,
enabled: true,
};
function TrunkFormDialog({
open,
onOpenChange,
trunk,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
trunk: Trunk | null;
}) {
const isEdit = Boolean(trunk);
const queryClient = useQueryClient();
const { toast } = useToast();
const [form, setForm] = React.useState<TrunkInput>(EMPTY_FORM);
React.useEffect(() => {
if (open) {
setForm(
trunk
? {
host: trunk.host,
port: trunk.port,
username: trunk.username ?? '',
callerId: trunk.callerId ?? '',
context: trunk.context,
maxCps: trunk.maxCps,
enabled: trunk.enabled,
}
: EMPTY_FORM,
);
}
}, [open, trunk]);
const mutation = useMutation({
mutationFn: () =>
isEdit && trunk ? trunksService.update(trunk.id, form) : trunksService.create(form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trunks'] });
toast({ title: 'Tronco 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 tronco' : 'Novo tronco'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
{!isEdit && (
<div className="grid grid-cols-2 gap-3">
<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>Tipo</Label>
<Select
value={form.type}
onValueChange={(v) => setForm((f) => ({ ...f, type: v as TrunkInput['type'] }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IP">IP</SelectItem>
<SelectItem value="REGISTRATION">Registration</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="host">Host</Label>
<Input
id="host"
required
value={form.host}
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="port">Porta</Label>
<Input
id="port"
type="number"
value={form.port ?? 5060}
onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="username">Usuário</Label>
<Input
id="username"
value={form.username ?? ''}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">Senha {isEdit && '(deixe em branco para manter)'}</Label>
<Input
id="password"
type="password"
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<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>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxCps">CPS máximo</Label>
<Input
id="maxCps"
type="number"
min={1}
required
value={form.maxCps}
onChange={(e) => setForm((f) => ({ ...f, maxCps: Number(e.target.value) }))}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function TrunksContent() {
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<Trunk | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, string>>({});
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['trunks'],
queryFn: trunksService.list,
});
const remove = useMutation({
mutationFn: (id: string) => trunksService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trunks'] });
toast({ title: 'Tronco removido', variant: 'success' });
},
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const checkStatus = useMutation({
mutationFn: (id: string) => trunksService.status(id),
onSuccess: (result, id) =>
setStatusById((prev) => ({ ...prev, [id]: result.status })),
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
});
const filtered = (data ?? []).filter((t) =>
t.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
t.host.includes(debouncedSearch),
);
const columns: DataTableColumn<Trunk>[] = [
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'type', header: 'Tipo', render: (r) => r.type },
{ key: 'host', header: 'Host', render: (r) => `${r.host}:${r.port}` },
{ key: 'maxCps', header: 'CPS máx.', render: (r) => r.maxCps },
{
key: 'status',
header: 'Status',
render: (r) =>
statusById[r.id] ? (
<Badge variant={statusById[r.id] === 'ok' ? 'success' : 'destructive'}>
{statusById[r.id]}
</Badge>
) : (
<Badge variant={r.enabled ? 'secondary' : 'outline'}>
{r.enabled ? 'Ativo' : 'Inativo'}
</Badge>
),
},
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) => (
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" onClick={() => checkStatus.mutate(r.id)}>
<Activity className="size-4" />
</Button>
{can('trunks.update') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
)}
{can('trunks.delete') && (
<Button
size="icon"
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm(`Remover o tronco ${r.name}?`)) remove.mutate(r.id);
}}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
),
},
];
return (
<>
<PageHeader
title="Troncos"
description="Troncos SIP de saída para operadoras/gateways"
actions={
can('trunks.create') && (
<Button
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus /> Novo tronco
</Button>
)
}
/>
<DataTable
columns={columns}
data={filtered}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="Buscar por nome ou host..."
emptyMessage="Nenhum tronco cadastrado."
/>
<TrunkFormDialog open={dialogOpen} onOpenChange={setDialogOpen} trunk={editing} />
</>
);
}
export default function TrunksPage() {
return (
<RequirePermission permission="trunks.view">
<TrunksContent />
</RequirePermission>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil } 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 { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/toast';
import { useAuth } from '@/hooks/use-auth';
import { usersService, type CreateUserInput, type UpdateUserInput } from '@/services/users';
import { rolesService } from '@/services/roles';
import type { UserRecord } from '@/types';
import { errorMessage } from '@/lib/error-message';
import { formatDateTime } from '@/lib/utils';
function UserFormDialog({
open,
onOpenChange,
user,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
user: UserRecord | null;
}) {
const isEdit = Boolean(user);
const queryClient = useQueryClient();
const { toast } = useToast();
const [name, setName] = React.useState('');
const [email, setEmail] = React.useState('');
const [isActive, setIsActive] = React.useState(true);
const [roleIds, setRoleIds] = React.useState<string[]>([]);
const { data: roles } = useQuery({ queryKey: ['roles'], queryFn: rolesService.list, enabled: open });
React.useEffect(() => {
if (open) {
setName(user?.name ?? '');
setEmail(user?.email ?? '');
setIsActive(user?.isActive ?? true);
setRoleIds(user?.roles.map((r) => r.id) ?? []);
}
}, [open, user]);
const mutation = useMutation({
mutationFn: () => {
if (isEdit && user) {
const input: UpdateUserInput = { name, isActive, roleIds };
return usersService.update(user.id, input);
}
const input: CreateUserInput = { name, email, roleIds };
return usersService.create(input);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast({ title: 'Usuário 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 usuário' : 'Novo usuário'}</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="name">Nome</Label>
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
</div>
{!isEdit && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="email">E-mail</Label>
<Input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
)}
{isEdit && (
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<Label htmlFor="isActive">Usuário ativo</Label>
<Switch id="isActive" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
<div className="flex flex-col gap-2">
<Label>Perfis</Label>
<div className="flex flex-col gap-2 rounded-md border border-border p-3">
{(roles ?? []).map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm">
<Checkbox
checked={roleIds.includes(r.id)}
onCheckedChange={(checked) =>
setRoleIds((prev) =>
checked ? [...prev, r.id] : prev.filter((id) => id !== r.id),
)
}
/>
{r.name}
</label>
))}
</div>
</div>
<DialogFooter>
<Button type="submit" loading={mutation.isPending} disabled={roleIds.length === 0}>
Salvar
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function Content() {
const { can } = useAuth();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editing, setEditing] = React.useState<UserRecord | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['users'],
queryFn: usersService.list,
});
const columns: DataTableColumn<UserRecord>[] = [
{ key: 'name', header: 'Nome', render: (r) => r.name },
{ key: 'email', header: 'E-mail', render: (r) => r.email },
{
key: 'roles',
header: 'Perfis',
render: (r) => (
<div className="flex flex-wrap gap-1">
{r.roles.map((role) => (
<Badge key={role.id} variant="secondary">{role.name}</Badge>
))}
</div>
),
},
{
key: 'isActive',
header: 'Status',
render: (r) => <Badge variant={r.isActive ? 'success' : 'secondary'}>{r.isActive ? 'Ativo' : 'Inativo'}</Badge>,
},
{ key: 'lastLoginAt', header: 'Último login', render: (r) => formatDateTime(r.lastLoginAt) },
{
key: 'actions',
header: '',
className: 'text-right',
render: (r) =>
can('users.update') ? (
<Button
size="icon"
variant="ghost"
onClick={() => {
setEditing(r);
setDialogOpen(true);
}}
>
<Pencil className="size-4" />
</Button>
) : null,
},
];
return (
<>
<PageHeader
title="Usuários"
description="Contas de acesso ao sistema"
actions={
can('users.create') && (
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus /> Novo usuário
</Button>
)
}
/>
<DataTable
columns={columns}
data={data}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
rowKey={(r) => r.id}
emptyMessage="Nenhum usuário cadastrado."
/>
<UserFormDialog open={dialogOpen} onOpenChange={setDialogOpen} user={editing} />
</>
);
}
export default function UsersPage() {
return (
<RequirePermission permission="users.view">
<Content />
</RequirePermission>
);
}

View File

@@ -0,0 +1,126 @@
@import 'tailwindcss';
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: oklch(0.99 0.002 260);
--foreground: oklch(0.22 0.02 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.22 0.02 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.22 0.02 260);
--primary: oklch(0.52 0.19 265);
--primary-foreground: oklch(0.98 0.005 260);
--secondary: oklch(0.96 0.01 260);
--secondary-foreground: oklch(0.28 0.02 260);
--muted: oklch(0.96 0.01 260);
--muted-foreground: oklch(0.52 0.02 260);
--accent: oklch(0.94 0.03 265);
--accent-foreground: oklch(0.28 0.05 265);
--destructive: oklch(0.58 0.22 27);
--destructive-foreground: oklch(0.98 0.005 260);
--success: oklch(0.62 0.17 148);
--success-foreground: oklch(0.98 0.005 260);
--warning: oklch(0.75 0.16 75);
--warning-foreground: oklch(0.22 0.02 260);
--border: oklch(0.9 0.01 260);
--input: oklch(0.9 0.01 260);
--ring: oklch(0.52 0.19 265);
--sidebar: oklch(0.18 0.02 260);
--sidebar-foreground: oklch(0.9 0.01 260);
--sidebar-muted: oklch(0.65 0.02 260);
--sidebar-accent: oklch(0.27 0.03 260);
--sidebar-border: oklch(0.27 0.02 260);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.16 0.015 260);
--foreground: oklch(0.93 0.01 260);
--card: oklch(0.2 0.015 260);
--card-foreground: oklch(0.93 0.01 260);
--popover: oklch(0.2 0.015 260);
--popover-foreground: oklch(0.93 0.01 260);
--primary: oklch(0.68 0.17 265);
--primary-foreground: oklch(0.16 0.02 260);
--secondary: oklch(0.26 0.015 260);
--secondary-foreground: oklch(0.9 0.01 260);
--muted: oklch(0.26 0.015 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.3 0.04 265);
--accent-foreground: oklch(0.9 0.03 265);
--destructive: oklch(0.62 0.2 27);
--destructive-foreground: oklch(0.98 0.005 260);
--success: oklch(0.68 0.16 148);
--success-foreground: oklch(0.16 0.02 260);
--warning: oklch(0.78 0.15 75);
--warning-foreground: oklch(0.16 0.02 260);
--border: oklch(0.3 0.015 260);
--input: oklch(0.3 0.015 260);
--ring: oklch(0.68 0.17 265);
--sidebar: oklch(0.12 0.015 260);
--sidebar-foreground: oklch(0.88 0.01 260);
--sidebar-muted: oklch(0.58 0.02 260);
--sidebar-accent: oklch(0.22 0.02 260);
--sidebar-border: oklch(0.24 0.015 260);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-muted: var(--sidebar-muted);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-border: var(--sidebar-border);
--radius-sm: calc(var(--radius) - 0.25rem);
--radius-md: calc(var(--radius) - 0.125rem);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 0.25rem);
}
* {
border-color: var(--color-border);
}
body {
background: var(--color-background);
color: var(--color-foreground);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 999px;
}
::-webkit-scrollbar-track {
background: transparent;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

View File

@@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import { Providers } from '@/components/providers';
import './globals.css';
export const metadata: Metadata = {
title: 'B2BCall',
description: 'Plataforma de discador preditivo e call center B2BCall',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<body className="antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import * as React from 'react';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { authService } from '@/services/auth';
import { errorMessage } from '@/lib/error-message';
export default function LoginPage() {
return (
<React.Suspense fallback={null}>
<LoginForm />
</React.Suspense>
);
}
function LoginForm() {
const router = useRouter();
const params = useSearchParams();
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const result = await authService.login(email, password);
if (result.mustChangePassword) {
router.push('/trocar-senha');
return;
}
const next = params.get('next') || '/';
window.location.href = next;
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-secondary/40 px-4">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<Image src="/logo.png" alt="B2BCall" width={64} height={64} className="mb-2 rounded-xl" />
<CardTitle className="text-lg">B2BCall</CardTitle>
<CardDescription>Entre com suas credenciais para continuar</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="email">E-mail</Label>
<Input
id="email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">Senha</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="mt-1">
{loading && <Loader2 className="animate-spin" />}
Entrar
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import * as React from 'react';
import Image from 'next/image';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { authService } from '@/services/auth';
import { errorMessage } from '@/lib/error-message';
export default function ChangePasswordPage() {
const [current, setCurrent] = React.useState('');
const [next, setNext] = React.useState('');
const [confirm, setConfirm] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (next !== confirm) {
setError('As senhas não coincidem.');
return;
}
setLoading(true);
try {
await authService.changePassword(current, next);
window.location.href = '/';
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-secondary/40 px-4">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<Image src="/logo.png" alt="B2BCall" width={64} height={64} className="mb-2 rounded-xl" />
<CardTitle className="text-lg">Troca de senha obrigatória</CardTitle>
<CardDescription>
Defina uma nova senha para continuar utilizando o sistema.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="current">Senha atual</Label>
<Input
id="current"
type="password"
required
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="next">Nova senha</Label>
<Input
id="next"
type="password"
required
minLength={8}
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirm">Confirmar nova senha</Label>
<Input
id="confirm"
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="mt-1">
{loading && <Loader2 className="animate-spin" />}
Salvar nova senha
</Button>
</form>
</CardContent>
</Card>
</div>
);
}