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>
224 lines
8.0 KiB
TypeScript
224 lines
8.0 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 { Plug, Plus } from 'lucide-react';
|
|
|
|
import {
|
|
createEvolutionConfig,
|
|
deleteEvolutionConfig,
|
|
listEvolutionConfigs,
|
|
testarConexaoEvolution,
|
|
updateEvolutionConfig,
|
|
type EvolutionConfig,
|
|
} from '@/lib/api/evolution-config';
|
|
import {
|
|
evolutionConfigSchema,
|
|
type EvolutionConfigFormValues,
|
|
} from '@/lib/validators/evolution-config';
|
|
import { ApiError } from '@/lib/api/client';
|
|
import { formatDate } from '@/lib/format';
|
|
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 { ConfirmDialog } from '@/components/domain/confirm-dialog';
|
|
|
|
export default function EvolutionConfigPage() {
|
|
const queryClient = useQueryClient();
|
|
const { data: configs, isLoading } = useQuery({
|
|
queryKey: ['master', 'evolution-config'],
|
|
queryFn: listEvolutionConfigs,
|
|
});
|
|
|
|
const [editing, setEditing] = React.useState<EvolutionConfig | null>(null);
|
|
const [formOpen, setFormOpen] = React.useState(false);
|
|
const [deleting, setDeleting] = React.useState<EvolutionConfig | null>(null);
|
|
const [testingId, setTestingId] = React.useState<string | null>(null);
|
|
|
|
const form = useForm<EvolutionConfigFormValues>({ resolver: zodResolver(evolutionConfigSchema) });
|
|
|
|
function openCreate() {
|
|
setEditing(null);
|
|
form.reset({ nome: '', base_url: '', api_key: '', versao: '' });
|
|
setFormOpen(true);
|
|
}
|
|
|
|
function openEdit(config: EvolutionConfig) {
|
|
setEditing(config);
|
|
form.reset({ nome: config.nome, base_url: config.base_url, api_key: '', versao: config.versao });
|
|
setFormOpen(true);
|
|
}
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: (values: EvolutionConfigFormValues) => {
|
|
if (editing) {
|
|
return updateEvolutionConfig(editing.id, {
|
|
nome: values.nome,
|
|
base_url: values.base_url,
|
|
versao: values.versao || undefined,
|
|
...(values.api_key ? { api_key: values.api_key } : {}),
|
|
});
|
|
}
|
|
return createEvolutionConfig({
|
|
nome: values.nome,
|
|
base_url: values.base_url,
|
|
api_key: values.api_key,
|
|
versao: values.versao || undefined,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['master', 'evolution-config'] });
|
|
toast.success(editing ? 'Configuração atualizada.' : 'Configuração criada.');
|
|
setFormOpen(false);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof ApiError ? err.message : 'Não foi possível salvar a configuração.');
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => deleteEvolutionConfig(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['master', 'evolution-config'] });
|
|
toast.success('Configuração excluída.');
|
|
setDeleting(null);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof ApiError ? err.message : 'Não foi possível excluir a configuração.');
|
|
setDeleting(null);
|
|
},
|
|
});
|
|
|
|
const testMutation = useMutation({
|
|
mutationFn: (id: string) => testarConexaoEvolution(id),
|
|
onMutate: (id) => setTestingId(id),
|
|
onSuccess: (result) => {
|
|
toast.success(`Conexão OK (status ${result.status_http}${result.versao ? `, versão ${result.versao}` : ''}).`);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof ApiError ? err.message : 'Falha ao testar a conexão.');
|
|
},
|
|
onSettled: () => setTestingId(null),
|
|
});
|
|
|
|
const columns: DataTableColumn<EvolutionConfig>[] = [
|
|
{ id: 'nome', header: 'Nome', primary: true, cell: (row) => row.nome },
|
|
{ id: 'base_url', header: 'URL base', cell: (row) => row.base_url },
|
|
{ id: 'api_key', header: 'API key', cell: (row) => <code className="text-xs">{row.api_key}</code> },
|
|
{ id: 'versao', header: 'Versão', cell: (row) => row.versao },
|
|
{
|
|
id: 'status',
|
|
header: 'Status',
|
|
primary: true,
|
|
cell: (row) => <Badge variant={row.ativo ? 'default' : 'outline'}>{row.ativo ? 'Ativo' : 'Inativo'}</Badge>,
|
|
},
|
|
{ id: 'criado_em', header: 'Criado em', hideOnMobileCard: true, cell: (row) => formatDate(row.criado_em) },
|
|
];
|
|
|
|
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">Evolution API</h1>
|
|
<p className="text-sm text-muted-foreground">Conexão usada para disparar mensagens de WhatsApp</p>
|
|
</div>
|
|
<Button onClick={openCreate}>
|
|
<Plus /> Nova configuração
|
|
</Button>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={configs ?? []}
|
|
keyField={(row) => row.id}
|
|
loading={isLoading}
|
|
emptyTitle="Nenhuma configuração cadastrada"
|
|
emptyDescription="Cadastre a conexão com a Evolution API para habilitar o envio de WhatsApp."
|
|
emptyAction={
|
|
<Button size="sm" onClick={openCreate}>
|
|
Nova configuração
|
|
</Button>
|
|
}
|
|
rowActions={(row) => (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
loading={testingId === row.id}
|
|
onClick={() => testMutation.mutate(row.id)}
|
|
>
|
|
<Plug /> Testar conexão
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => openEdit(row)}>
|
|
Editar
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setDeleting(row)}>
|
|
Excluir
|
|
</Button>
|
|
</>
|
|
)}
|
|
/>
|
|
|
|
<ResponsiveDialog
|
|
open={formOpen}
|
|
onOpenChange={setFormOpen}
|
|
title={editing ? 'Editar configuração' : 'Nova configuração'}
|
|
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))}
|
|
>
|
|
<FormField label="Nome" error={form.formState.errors.nome?.message} required>
|
|
<Input {...form.register('nome')} />
|
|
</FormField>
|
|
<FormField label="URL base" error={form.formState.errors.base_url?.message} required>
|
|
<Input placeholder="https://evolution.exemplo.com" {...form.register('base_url')} />
|
|
</FormField>
|
|
<FormField
|
|
label="API key"
|
|
error={form.formState.errors.api_key?.message}
|
|
helpText={editing ? 'Deixe em branco para manter a chave atual' : undefined}
|
|
required={!editing}
|
|
>
|
|
<Input type="password" autoComplete="off" {...form.register('api_key')} />
|
|
</FormField>
|
|
<FormField label="Versão" error={form.formState.errors.versao?.message}>
|
|
<Input placeholder="2.3" {...form.register('versao')} />
|
|
</FormField>
|
|
</form>
|
|
</ResponsiveDialog>
|
|
|
|
<ConfirmDialog
|
|
open={deleting !== null}
|
|
onOpenChange={(open) => !open && setDeleting(null)}
|
|
title={`Excluir "${deleting?.nome}"?`}
|
|
description="Só é possível excluir se nenhuma instância de WhatsApp estiver usando esta configuração."
|
|
confirmLabel="Excluir"
|
|
destructive
|
|
loading={deleteMutation.isPending}
|
|
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|