Files
b2bcall/apps/frontend/src/app/(app)/bloqueio/page.tsx
B2BCall Bootstrap fa8339003d fix: bug sistêmico no cliente HTTP quebrava toda ação sem payload
Causa raiz do 'body is empty' relatado pelo usuário ao clicar em
redefinir senha do ramal: o cliente sempre mandava Content-Type:
application/json mesmo em requisições sem corpo (POST/PATCH de ação, ex.:
reset-password, agent-console available/pause/unpause/logout, dialplan
publish/rollback, campanha start/pause/stop/drain). O Fastify rejeita
isso com 400 'Body cannot be empty when content-type is set to
application/json' — nunca detectado antes porque todo teste anterior foi
via curl sem fixar esse header, não reproduzindo o comportamento real do
fetch() do navegador. Corrigido na única função request() central
(apps/frontend/src/lib/api-client.ts): só envia o header quando há de
fato um corpo.

Bug relacionado encontrado na mesma revisão: DELETE /api/suppression/:id
exige removalReason no corpo, mas api.delete() nem aceitava um argumento
de corpo — o botão 'Desbloquear' da lista de bloqueio sempre falhava com
403. api.delete() agora aceita body opcional; a tela pede o motivo via
prompt antes de remover.
2026-08-27 20:48:00 -03:00

209 lines
7.0 KiB
TypeScript

'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, removalReason }: { id: string; removalReason: string }) =>
suppressionService.remove(id, removalReason),
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={() => {
const removalReason = window.prompt(
`Motivo para desbloquear ${r.phoneNormalized} (obrigatório):`,
);
if (removalReason && removalReason.trim().length > 0) {
remove.mutate({ id: r.id, removalReason: removalReason.trim() });
}
}}
>
<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>
);
}