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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user