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,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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user