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:
2026-08-16 09:41:13 -03:00
co-authored by Claude Sonnet 5
parent f9738e54da
commit 323912748f
179 changed files with 29312 additions and 22 deletions
@@ -0,0 +1,22 @@
import { z } from 'zod';
import { CANAIS_TEMPLATE, TIPOS_TEMPLATE } from '@/lib/api/alert-templates';
/**
* Espelha `CreateMasterAlertTemplateDto` — `assunto` é obrigatório só para
* `canal: 'email'` (mesmo `@ValidateIf` do backend), daí o `.refine` no
* objeto inteiro em vez de no campo isolado.
*/
export const alertTemplateSchema = z
.object({
tipo: z.enum(TIPOS_TEMPLATE, 'Selecione um tipo'),
canal: z.enum(CANAIS_TEMPLATE, 'Selecione um canal'),
assunto: z.string().optional(),
corpo: z.string().min(1, 'Informe o corpo da mensagem'),
ativo: z.boolean(),
})
.refine((data) => data.canal !== 'email' || (data.assunto?.trim().length ?? 0) > 0, {
message: 'Assunto é obrigatório para templates de e-mail',
path: ['assunto'],
});
export type AlertTemplateFormValues = z.infer<typeof alertTemplateSchema>;
+31
View File
@@ -0,0 +1,31 @@
import { z } from 'zod';
/** Espelha `LoginDto` do backend (`src/modules/auth/dto/login.dto.ts`). */
export const loginSchema = z.object({
email: z.email('E-mail inválido').min(1, 'Informe o e-mail'),
senha: z.string().min(1, 'Informe a senha'),
});
export type LoginFormValues = z.infer<typeof loginSchema>;
/** Espelha `ForgotPasswordDto`. */
export const forgotPasswordSchema = z.object({
email: z.email('E-mail inválido').min(1, 'Informe o e-mail'),
});
export type ForgotPasswordFormValues = z.infer<typeof forgotPasswordSchema>;
/**
* Espelha `ResetPasswordDto` — mínimo de 10 caracteres
* (`src/modules/auth/password.util.ts`, `MIN_LENGTH`). A lista de senhas
* trivialmente fracas é bloqueada só no backend (não duplicada aqui); o
* erro retornado por lá já é claro o bastante para mostrar direto.
*/
export const resetPasswordSchema = z
.object({
novaSenha: z.string().min(10, 'A senha precisa ter no mínimo 10 caracteres'),
confirmarSenha: z.string().min(1, 'Confirme a nova senha'),
})
.refine((data) => data.novaSenha === data.confirmarSenha, {
message: 'As senhas não coincidem',
path: ['confirmarSenha'],
});
export type ResetPasswordFormValues = z.infer<typeof resetPasswordSchema>;
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
/** Espelha `DOCUMENTO_REGEX` (`src/modules/customers/documento.util.ts`) — CPF/CNPJ, sem dígito verificador. */
const DOCUMENTO_REGEX = /^(\d{11}|\d{14}|\d{3}\.\d{3}\.\d{3}-\d{2}|\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2})$/;
/** Espelha `CreateCustomerDto`/`UpdateCustomerDto` (`src/modules/customers/dto/`). */
export const customerSchema = z.object({
razao_social: z.string().min(2, 'Informe pelo menos 2 caracteres'),
nome_fantasia: z.string().optional(),
documento: z
.string()
.optional()
.refine((v) => !v || DOCUMENTO_REGEX.test(v), { message: 'Informe um CPF ou CNPJ válido' }),
email: z.email('E-mail inválido').optional().or(z.literal('')),
telefone_whatsapp: z.string().optional(),
endereco: z.string().optional(),
cidade: z.string().optional(),
uf: z.string().length(2, 'UF deve ter 2 letras').optional().or(z.literal('')),
observacoes: z.string().optional(),
});
export type CustomerFormValues = z.infer<typeof customerSchema>;
/** Espelha `CreateContactDto`/`UpdateContactDto`. */
export const customerContactSchema = z.object({
nome: z.string().min(2, 'Informe pelo menos 2 caracteres'),
cargo: z.string().optional(),
email: z.email('E-mail inválido').optional().or(z.literal('')),
telefone_whatsapp: z.string().optional(),
principal: z.boolean(),
});
export type CustomerContactFormValues = z.infer<typeof customerContactSchema>;
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
/**
* Espelha `UpdateTenantEmailConfigDto`: quando `usar_config_padrao` é
* `false`, todos os campos de SMTP próprio ficam obrigatórios (mesma
* validação condicional do `@ValidateIf` do backend).
*/
export const tenantEmailConfigSchema = z
.object({
usar_config_padrao: z.boolean(),
smtp_host: z.string().optional(),
smtp_porta: z.number().int().min(1).max(65535).optional(),
smtp_usuario: z.string().optional(),
smtp_senha: z.string().optional(),
email_remetente: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.usar_config_padrao) return;
if (!data.smtp_host || data.smtp_host.length < 2) {
ctx.addIssue({ code: 'custom', path: ['smtp_host'], message: 'Informe o host do SMTP' });
}
if (!data.smtp_porta) {
ctx.addIssue({ code: 'custom', path: ['smtp_porta'], message: 'Informe a porta do SMTP' });
}
if (!data.smtp_usuario) {
ctx.addIssue({ code: 'custom', path: ['smtp_usuario'], message: 'Informe o usuário do SMTP' });
}
if (!data.smtp_senha) {
ctx.addIssue({ code: 'custom', path: ['smtp_senha'], message: 'Informe a senha do SMTP' });
}
if (!data.email_remetente || !z.email().safeParse(data.email_remetente).success) {
ctx.addIssue({ code: 'custom', path: ['email_remetente'], message: 'E-mail remetente inválido' });
}
});
export type TenantEmailConfigFormValues = z.infer<typeof tenantEmailConfigSchema>;
export const testarEnvioEmailSchema = z.object({
destinatario: z.email('E-mail inválido'),
});
export type TestarEnvioEmailFormValues = z.infer<typeof testarEnvioEmailSchema>;
+22
View File
@@ -0,0 +1,22 @@
import { z } from 'zod';
import { EQUIPE_ROLES } from '@/lib/api/equipe';
/** Espelha `CreateEquipeUserDto` (`src/modules/equipe/dto/create-equipe-user.dto.ts`). */
export const createEquipeUserSchema = z.object({
nome: z.string().min(2, 'Informe pelo menos 2 caracteres'),
email: z.email('E-mail inválido'),
role: z.enum(EQUIPE_ROLES, { message: 'Selecione um papel' }),
// Vazio = backend gera senha provisória automaticamente.
senha: z.string().refine((v) => v.length === 0 || v.length >= 10, {
message: 'A senha deve ter pelo menos 10 caracteres (ou deixe em branco para gerar uma)',
}),
});
export type CreateEquipeUserFormValues = z.infer<typeof createEquipeUserSchema>;
/** Espelha `UpdateEquipeUserDto` — usado no diálogo "Alterar papel". */
export const updateEquipeRoleSchema = z.object({
role: z.enum(EQUIPE_ROLES, { message: 'Selecione um papel' }),
});
export type UpdateEquipeRoleFormValues = z.infer<typeof updateEquipeRoleSchema>;
@@ -0,0 +1,21 @@
import { z } from 'zod';
/**
* Espelha `CreateEvolutionConfigDto`/`UpdateEvolutionConfigDto`. `api_key`
* fica opcional aqui mesmo no create — a obrigatoriedade (mínimo 8
* caracteres quando preenchida) é a mesma nos dois casos; no formulário de
* edição, vazio significa "manter a chave atual" (a API nunca devolve o
* valor completo pra reenviar).
*/
export const evolutionConfigSchema = z.object({
nome: z.string().min(2, 'Informe pelo menos 2 caracteres'),
base_url: z.url('Informe uma URL válida (com http:// ou https://)'),
api_key: z
.string()
.refine((v) => v.length === 0 || v.length >= 8, {
message: 'A API key deve ter pelo menos 8 caracteres',
}),
versao: z.string().optional(),
});
export type EvolutionConfigFormValues = z.infer<typeof evolutionConfigSchema>;
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
/**
* Campos numéricos ficam como `z.number()` puro (sem `coerce`/`preprocess`)
* de propósito — a conversão string→number do `<input type="number">`
* acontece no `register(...)` do react-hook-form (`valueAsNumber`/
* `setValueAs`), não no schema. Misturar as duas coisas faz o tipo de
* entrada do formulário (`z.input`) divergir do tipo de saída (`z.infer`) e
* quebra a inferência de tipos do `zodResolver`.
*/
const optionalInt = z.number().int().min(1, 'Deve ser pelo menos 1').optional();
/** Espelha `CreatePlanoDto`/`UpdatePlanoDto` (`src/modules/planos/dto/`). */
export const planoSchema = z.object({
nome: z.string().min(2, 'Informe pelo menos 2 caracteres'),
descricao: z.string().optional(),
valor_mensal: z.number('Informe um valor').positive('O valor deve ser maior que zero'),
limite_customers: optionalInt,
limite_usuarios: optionalInt,
limite_instancias_whatsapp: optionalInt,
});
export type PlanoFormValues = z.infer<typeof planoSchema>;
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod';
/** Espelha `CreateProductDto`/`UpdateProductDto` (`src/modules/products/dto/`). */
export const productSchema = z.object({
tipo: z.enum(['produto', 'servico'], 'Selecione o tipo'),
nome: z.string().min(2, 'Informe pelo menos 2 caracteres'),
descricao: z.string().optional(),
sku: z.string().optional(),
valor_venda: z.number('Informe um valor').positive('O valor deve ser maior que zero'),
valor_custo: z.number().positive('O valor deve ser maior que zero').optional(),
unidade: z.string().optional(),
recorrente: z.boolean(),
});
export type ProductFormValues = z.infer<typeof productSchema>;
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod';
/** Item da venda no formulário — inclui campos de exibição (`nome`/`valor_catalogo`) não enviados à API. */
export const saleItemFormSchema = z.object({
product_service_id: z.string().min(1, 'Selecione um produto'),
nome: z.string(),
quantidade: z.number().positive('Informe uma quantidade válida'),
valor_unitario: z.number().nonnegative('Informe um valor válido'),
valor_catalogo: z.number(),
});
export type SaleItemFormValues = z.infer<typeof saleItemFormSchema>;
export const saleParcelaFormSchema = z.object({
valor: z.number().positive('Informe um valor válido'),
vencimento: z.string().min(1, 'Informe o vencimento'),
});
export type SaleParcelaFormValues = z.infer<typeof saleParcelaFormSchema>;
/**
* Campos "de topo" da venda. A validação cruzada entre modo de parcelas e
* soma-bate-com-total (mesma regra do backend, `sales.service.ts`) é
* calculada ao vivo no componente, não aqui — dá pra mostrar a diferença
* de forma incremental enquanto o usuário edita, em vez de só no submit.
*/
export const saleFormSchema = z.object({
customer_id: z.string().min(1, 'Selecione um cliente'),
data_venda: z.string().min(1, 'Informe a data da venda'),
desconto: z.number().min(0, 'O desconto não pode ser negativo'),
observacoes: z.string().optional(),
itens: z.array(saleItemFormSchema).min(1, 'Adicione pelo menos um item'),
modoParcelas: z.enum(['automatico', 'manual']),
numeroParcelas: z.number().int().min(1).optional(),
parcelasManuais: z.array(saleParcelaFormSchema).optional(),
});
export type SaleFormValues = z.infer<typeof saleFormSchema>;
@@ -0,0 +1,18 @@
import { z } from 'zod';
import { CANAIS_TEMPLATE, TIPOS_TEMPLATE_TENANT } from '@/lib/api/tenant-alert-templates';
/** Espelha `CreateTenantAlertTemplateDto` — mesma regra condicional de `assunto` do validador master. */
export const tenantAlertTemplateSchema = z
.object({
tipo: z.enum(TIPOS_TEMPLATE_TENANT, 'Selecione um tipo'),
canal: z.enum(CANAIS_TEMPLATE, 'Selecione um canal'),
assunto: z.string().optional(),
corpo: z.string().min(1, 'Informe o corpo da mensagem'),
ativo: z.boolean(),
})
.refine((data) => data.canal !== 'email' || (data.assunto?.trim().length ?? 0) > 0, {
message: 'Assunto é obrigatório para templates de e-mail',
path: ['assunto'],
});
export type TenantAlertTemplateFormValues = z.infer<typeof tenantAlertTemplateSchema>;
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod';
/** Espelha `UpdatePerfilDto` (`src/modules/tenant-profile/dto/update-perfil.dto.ts`). */
export const updateTenantProfileSchema = z.object({
nome_fantasia: z.string().min(1, 'Informe o nome fantasia'),
razao_social: z.string().min(2, 'Informe pelo menos 2 caracteres'),
});
export type UpdateTenantProfileFormValues = z.infer<typeof updateTenantProfileSchema>;
/** Espelha `UpdateCorDto` (`src/modules/tenant-profile/dto/update-cor.dto.ts`). */
export const updateTenantCorSchema = z.object({
cor_primaria: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Informe uma cor no formato hex "#RRGGBB"'),
});
export type UpdateTenantCorFormValues = z.infer<typeof updateTenantCorSchema>;
+40
View File
@@ -0,0 +1,40 @@
import { z } from 'zod';
/**
* `dia_vencimento_mensalidade` fica como `z.number()` puro — a conversão
* string→number do input acontece no `register(...)` do react-hook-form
* (`setValueAs`), não no schema (ver `lib/validators/planos.ts`).
*/
const diaVencimento = z.number().int().min(1).max(28).optional();
/** Espelha `CreateTenantDto` (`src/modules/tenants/dto/create-tenant.dto.ts`). */
export const createTenantSchema = z.object({
razao_social: z.string().min(2, 'Informe pelo menos 2 caracteres'),
nome_fantasia: z.string().optional(),
documento: z.string().optional(),
email_contato: z.email('E-mail inválido'),
telefone: z.string().optional(),
plano_id: z.string().min(1, 'Selecione um plano'),
dia_vencimento_mensalidade: diaVencimento,
admin_nome: z.string().min(2, 'Informe o nome do responsável'),
admin_email: z.email('E-mail inválido'),
// Vazio = backend gera senha provisória automaticamente.
admin_senha: z.string().refine((v) => v.length === 0 || v.length >= 8, {
message: 'A senha deve ter pelo menos 8 caracteres (ou deixe em branco para gerar uma)',
}),
});
export type CreateTenantFormValues = z.infer<typeof createTenantSchema>;
/** Espelha `UpdateTenantDto` — mesmos campos cadastrais, sem `admin`. */
export const updateTenantSchema = z.object({
razao_social: z.string().min(2, 'Informe pelo menos 2 caracteres'),
nome_fantasia: z.string().optional(),
documento: z.string().optional(),
email_contato: z.email('E-mail inválido'),
telefone: z.string().optional(),
plano_id: z.string().min(1, 'Selecione um plano'),
dia_vencimento_mensalidade: diaVencimento,
});
export type UpdateTenantFormValues = z.infer<typeof updateTenantSchema>;
+11
View File
@@ -0,0 +1,11 @@
import { z } from 'zod';
/** Espelha `TestarEnvioWhatsappDto` (`src/modules/whatsapp/dto/testar-envio-whatsapp.dto.ts`). */
export const testarEnvioWhatsappSchema = z.object({
numero: z
.string()
.regex(/^\d{10,15}$/, 'Informe apenas dígitos (DDI+DDD+número), de 10 a 15 caracteres'),
mensagem: z.string().optional(),
});
export type TestarEnvioWhatsappFormValues = z.infer<typeof testarEnvioWhatsappSchema>;