Files
b2bcall/apps/frontend/src/app/(app)/monitoramento/campanhas/page.tsx
B2BCall Bootstrap 6273b32214 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).
2026-08-27 17:35:34 -03:00

108 lines
3.8 KiB
TypeScript

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