- 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).
237 lines
8.8 KiB
TypeScript
237 lines
8.8 KiB
TypeScript
'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>
|
|
);
|
|
}
|