Files
SeConectai_Vendas/frontend/app/master/alertas/logs/page.tsx
T
tiagoandClaude Sonnet 5 323912748f Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)
Adiciona o frontend Next.js em frontend/ (setup, design system,
autenticação/tema, layout/navegação, área master, área tenant completa:
clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas,
equipe e perfil). Estende o backend de forma aditiva e autorizada:
cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe +
edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo
e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:41:13 -03:00

210 lines
7.2 KiB
TypeScript

'use client';
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import type { DateRange } from 'react-day-picker';
import {
ALERT_LOG_STATUSES,
listAlertLogs,
type AlertLog,
type AlertLogStatus,
} from '@/lib/api/alert-logs';
import { CANAIS_TEMPLATE, CANAL_TEMPLATE_LABEL, TIPO_TEMPLATE_LABEL } from '@/lib/api/alert-templates';
import type { CanalTemplate } from '@/lib/api/alert-templates';
import { formatDateTime } from '@/lib/format';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { DateRangePicker } from '@/components/ui/date-picker';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
const STATUS_LABEL: Record<AlertLogStatus, string> = {
enviado: 'Enviado',
entregue: 'Entregue',
lido: 'Lido',
falhou: 'Falhou',
};
const STATUS_TONE: Record<AlertLogStatus, 'default' | 'destructive' | 'outline'> = {
enviado: 'outline',
entregue: 'default',
lido: 'default',
falhou: 'destructive',
};
const STATUS_OPTIONS: { value: AlertLogStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
...ALERT_LOG_STATUSES.map((s) => ({ value: s, label: STATUS_LABEL[s] })),
];
const CANAL_OPTIONS: { value: CanalTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os canais' },
...CANAIS_TEMPLATE.map((c) => ({ value: c, label: CANAL_TEMPLATE_LABEL[c] })),
];
const LIMIT = 20;
export default function AlertLogsPage() {
const [canal, setCanal] = React.useState<CanalTemplate | 'todos'>('todos');
const [status, setStatus] = React.useState<AlertLogStatus | 'todos'>('todos');
const [periodo, setPeriodo] = React.useState<DateRange | undefined>();
const [page, setPage] = React.useState(1);
const [detail, setDetail] = React.useState<AlertLog | null>(null);
const { data, isLoading } = useQuery({
queryKey: [
'master',
'alert-logs',
{ canal, status, de: periodo?.from?.toISOString(), ate: periodo?.to?.toISOString(), page },
],
queryFn: () =>
listAlertLogs({
canal: canal === 'todos' ? undefined : canal,
status: status === 'todos' ? undefined : status,
data_inicio: periodo?.from?.toISOString(),
data_fim: periodo?.to?.toISOString(),
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const columns: DataTableColumn<AlertLog>[] = [
{
id: 'tipo',
header: 'Tipo',
primary: true,
cell: (row) => (row.template ? TIPO_TEMPLATE_LABEL[row.template.tipo] : '—'),
},
{ id: 'canal', header: 'Canal', primary: true, cell: (row) => CANAL_TEMPLATE_LABEL[row.canal] },
{
id: 'status',
header: 'Status',
cell: (row) => <Badge variant={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>,
},
{ id: 'enviado_em', header: 'Enviado em', cell: (row) => formatDateTime(row.enviado_em) },
];
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Logs de alerta</h1>
<p className="text-sm text-muted-foreground">Histórico de envios de WhatsApp/e-mail</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Select
value={canal}
onValueChange={(value) => {
setCanal(value as CanalTemplate | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por canal">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as AlertLogStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-44" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DateRangePicker
value={periodo}
onChange={(range) => {
setPeriodo(range);
setPage(1);
}}
placeholder="Período"
className="w-full sm:w-64"
/>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum log encontrado"
emptyDescription="Ajuste os filtros de período/canal/status."
onRowClick={(row) => setDetail(row)}
rowActions={() => (
<Button variant="ghost" size="sm">
Ver detalhes
</Button>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ResponsiveDialog
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title="Detalhe do envio"
description={detail ? formatDateTime(detail.enviado_em) : undefined}
>
{detail && (
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<div className="flex flex-wrap gap-4 text-sm">
<div>
<p className="text-muted-foreground">Tipo</p>
<p className="font-medium text-foreground">
{detail.template ? TIPO_TEMPLATE_LABEL[detail.template.tipo] : '—'}
</p>
</div>
<div>
<p className="text-muted-foreground">Canal</p>
<p className="font-medium text-foreground">{CANAL_TEMPLATE_LABEL[detail.canal]}</p>
</div>
<div>
<p className="text-muted-foreground">Status</p>
<Badge variant={STATUS_TONE[detail.status]}>{STATUS_LABEL[detail.status]}</Badge>
</div>
</div>
<Separator />
<div>
<p className="mb-1 text-sm font-medium text-foreground">Conteúdo enviado</p>
<p className="rounded-lg border border-border bg-muted/50 p-3 text-sm whitespace-pre-wrap">
{detail.conteudo_enviado || '—'}
</p>
</div>
{detail.erro && (
<div>
<p className="mb-1 text-sm font-medium text-danger">Erro</p>
<p className="rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm whitespace-pre-wrap text-danger">
{detail.erro}
</p>
</div>
)}
</div>
)}
</ResponsiveDialog>
</div>
);
}