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