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:
236
apps/frontend/src/app/(app)/relatorios/chamadas/page.tsx
Normal file
236
apps/frontend/src/app/(app)/relatorios/chamadas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user