Files
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

350 lines
12 KiB
TypeScript

'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import {
CANAL_TEMPLATE_LABEL,
CANAIS_TEMPLATE,
PLACEHOLDERS_POR_TIPO,
TIPO_TEMPLATE_LABEL,
TIPOS_TEMPLATE,
createAlertTemplate,
deleteAlertTemplate,
listAlertTemplates,
updateAlertTemplate,
type AlertTemplate,
type CanalTemplate,
type TipoTemplate,
} from '@/lib/api/alert-templates';
import { alertTemplateSchema, type AlertTemplateFormValues } from '@/lib/validators/alert-templates';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
const TIPO_OPTIONS: { value: TipoTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os tipos' },
...TIPOS_TEMPLATE.map((tipo) => ({ value: tipo, label: TIPO_TEMPLATE_LABEL[tipo] })),
];
const CANAL_OPTIONS: { value: CanalTemplate | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os canais' },
...CANAIS_TEMPLATE.map((canal) => ({ value: canal, label: CANAL_TEMPLATE_LABEL[canal] })),
];
const LIMIT = 20;
export default function AlertTemplatesPage() {
const queryClient = useQueryClient();
const [tipo, setTipo] = React.useState<TipoTemplate | 'todos'>('todos');
const [canal, setCanal] = React.useState<CanalTemplate | 'todos'>('todos');
const [page, setPage] = React.useState(1);
const [editing, setEditing] = React.useState<AlertTemplate | null>(null);
const [formOpen, setFormOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState<AlertTemplate | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['master', 'alert-templates', { tipo, canal, page }],
queryFn: () =>
listAlertTemplates({
tipo: tipo === 'todos' ? undefined : tipo,
canal: canal === 'todos' ? undefined : canal,
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const form = useForm<AlertTemplateFormValues>({ resolver: zodResolver(alertTemplateSchema) });
const corpoFieldRef = React.useRef<HTMLTextAreaElement | null>(null);
const { ref: corpoRegisterRef, ...corpoRegister } = form.register('corpo');
const watchedTipo = form.watch('tipo');
const watchedCanal = form.watch('canal');
function openCreate() {
setEditing(null);
form.reset({ tipo: 'mensalidade_tenant', canal: 'whatsapp', assunto: '', corpo: '', ativo: true });
setFormOpen(true);
}
function openEdit(template: AlertTemplate) {
setEditing(template);
form.reset({
tipo: template.tipo,
canal: template.canal,
assunto: template.assunto ?? '',
corpo: template.corpo,
ativo: template.ativo,
});
setFormOpen(true);
}
function insertPlaceholder(chave: string) {
const token = `{{${chave}}}`;
const el = corpoFieldRef.current;
const current = form.getValues('corpo') ?? '';
if (!el) {
form.setValue('corpo', current + token, { shouldValidate: true });
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
const next = current.slice(0, start) + token + current.slice(end);
form.setValue('corpo', next, { shouldValidate: true });
requestAnimationFrame(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
}
const saveMutation = useMutation({
mutationFn: (values: AlertTemplateFormValues) => {
const payload = { ...values, assunto: values.canal === 'email' ? values.assunto : undefined };
return editing ? updateAlertTemplate(editing.id, payload) : createAlertTemplate(payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'alert-templates'] });
toast.success(editing ? 'Template atualizado.' : 'Template criado.');
setFormOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar o template.');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAlertTemplate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'alert-templates'] });
toast.success('Template desativado.');
setDeleting(null);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível desativar o template.');
setDeleting(null);
},
});
const columns: DataTableColumn<AlertTemplate>[] = [
{ id: 'tipo', header: 'Tipo', primary: true, cell: (row) => TIPO_TEMPLATE_LABEL[row.tipo] },
{ id: 'canal', header: 'Canal', primary: true, cell: (row) => CANAL_TEMPLATE_LABEL[row.canal] },
{
id: 'corpo',
header: 'Prévia',
cell: (row) => <span className="line-clamp-1 text-muted-foreground">{row.corpo}</span>,
},
{
id: 'status',
header: 'Status',
cell: (row) => <Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>,
},
];
const placeholders = watchedTipo ? PLACEHOLDERS_POR_TIPO[watchedTipo] : [];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Templates de alerta</h1>
<p className="text-sm text-muted-foreground">Padrões de mensagem da plataforma</p>
</div>
<Button onClick={openCreate}>
<Plus /> Novo template
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={tipo}
onValueChange={(value) => {
setTipo(value as TipoTemplate | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-56" aria-label="Filtrar por tipo">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<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>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum template cadastrado"
emptyAction={
<Button size="sm" onClick={openCreate}>
Novo template
</Button>
}
rowActions={(row) => (
<>
<Button variant="ghost" size="sm" onClick={() => openEdit(row)}>
Editar
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleting(row)}>
Desativar
</Button>
</>
)}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ResponsiveDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? 'Editar template' : 'Novo template'}
className="sm:max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setFormOpen(false)}>
Cancelar
</Button>
<Button
loading={saveMutation.isPending}
onClick={form.handleSubmit((values) => saveMutation.mutate(values))}
>
Salvar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Tipo" id="tipo" error={form.formState.errors.tipo?.message} required>
<Select
value={watchedTipo}
onValueChange={(value) => form.setValue('tipo', value as TipoTemplate, { shouldValidate: true })}
>
<SelectTrigger id="tipo" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_TEMPLATE.map((t) => (
<SelectItem key={t} value={t}>
{TIPO_TEMPLATE_LABEL[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="Canal" id="canal" error={form.formState.errors.canal?.message} required>
<Select
value={watchedCanal}
onValueChange={(value) => form.setValue('canal', value as CanalTemplate, { shouldValidate: true })}
>
<SelectTrigger id="canal" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CANAIS_TEMPLATE.map((c) => (
<SelectItem key={c} value={c}>
{CANAL_TEMPLATE_LABEL[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
{watchedCanal === 'email' && (
<FormField label="Assunto" error={form.formState.errors.assunto?.message} required>
<Input {...form.register('assunto')} />
</FormField>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_220px]">
<FormField label="Corpo da mensagem" error={form.formState.errors.corpo?.message} required>
<Textarea
rows={8}
{...corpoRegister}
ref={(el) => {
corpoRegisterRef(el);
corpoFieldRef.current = el;
}}
/>
</FormField>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground">Placeholders disponíveis</p>
<div className="flex flex-col gap-1.5 overflow-y-auto rounded-lg border border-border p-2 sm:max-h-56">
{placeholders.map((p) => (
<button
key={p.chave}
type="button"
onClick={() => insertPlaceholder(p.chave)}
title={p.descricao}
className="rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted"
>
<code className="font-mono font-medium text-primary-on-light">{`{{${p.chave}}}`}</code>
<p className="text-muted-foreground">{p.descricao}</p>
</button>
))}
</div>
</div>
</div>
</form>
</ResponsiveDialog>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => !open && setDeleting(null)}
title="Desativar template?"
description="O template deixa de ser usado nos disparos automáticos, mas continua no histórico."
confirmLabel="Desativar"
destructive
loading={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}