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