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>
210 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|