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.
209 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|