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
+35
View File
@@ -0,0 +1,35 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
import type { CanalTemplate, TipoTemplate } from '@/lib/api/alert-templates';
export const ALERT_LOG_STATUSES = ['enviado', 'falhou', 'entregue', 'lido'] as const;
export type AlertLogStatus = (typeof ALERT_LOG_STATUSES)[number];
/** Espelha o model `alert_logs` + `include: { template: { tipo } }`. */
export interface AlertLog {
id: string;
tenant_id: string | null;
destinatario_tipo: string;
destinatario_id: string;
canal: CanalTemplate;
template_id: string | null;
conteudo_enviado: string | null;
status: AlertLogStatus;
erro: string | null;
enviado_em: string;
template: { tipo: TipoTemplate } | null;
}
export interface ListAlertLogsParams {
canal?: CanalTemplate;
status?: AlertLogStatus;
data_inicio?: string;
data_fim?: string;
tenant_id?: string;
page?: number;
limit?: number;
}
export function listAlertLogs(params: ListAlertLogsParams) {
return apiClient.get<PaginatedResult<AlertLog>>(`/master/alertas/logs${buildQuery(params)}`);
}
+113
View File
@@ -0,0 +1,113 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
/** Espelha `alert-template-tipos.ts` do backend. */
export const TIPOS_TEMPLATE = [
'mensalidade_tenant',
'cobranca_cliente',
'venda_confirmada',
'lembrete_vencimento',
] as const;
export type TipoTemplate = (typeof TIPOS_TEMPLATE)[number];
export const CANAIS_TEMPLATE = ['whatsapp', 'email'] as const;
export type CanalTemplate = (typeof CANAIS_TEMPLATE)[number];
export const TIPO_TEMPLATE_LABEL: Record<TipoTemplate, string> = {
mensalidade_tenant: 'Mensalidade do tenant',
cobranca_cliente: 'Cobrança ao cliente',
venda_confirmada: 'Venda confirmada',
lembrete_vencimento: 'Lembrete de vencimento',
};
export const CANAL_TEMPLATE_LABEL: Record<CanalTemplate, string> = {
whatsapp: 'WhatsApp',
email: 'E-mail',
};
interface PlaceholderDoc {
chave: string;
descricao: string;
}
/**
* Espelha `src/modules/alerts/placeholders.ts` (`PLACEHOLDERS_POR_TIPO`) —
* não existe endpoint que exponha isso (é uma constante interna do
* backend, nunca importada por nenhum controller), então é replicada aqui
* manualmente para o editor de template desta etapa. Se o backend mudar
* essa lista, esta cópia precisa ser atualizada junto.
*/
export const PLACEHOLDERS_POR_TIPO: Record<TipoTemplate, PlaceholderDoc[]> = {
mensalidade_tenant: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do tenant' },
{ chave: 'valor', descricao: 'Valor da fatura, formatado como "R$ 199,90"' },
{ chave: 'vencimento', descricao: 'Data de vencimento da fatura, formato dd/mm/aaaa' },
{ chave: 'referencia', descricao: 'Mês/ano de referência da fatura, formato aaaa-mm' },
],
cobranca_cliente: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do customer' },
{ chave: 'valor', descricao: 'Valor da parcela, formatado como "R$ 199,90"' },
{ chave: 'vencimento', descricao: 'Data de vencimento da parcela, formato dd/mm/aaaa' },
{ chave: 'numero_venda', descricao: 'Número sequencial da venda' },
{ chave: 'numero_parcela', descricao: 'Número da parcela dentro da venda (1, 2, 3...)' },
],
venda_confirmada: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do customer' },
{ chave: 'numero_venda', descricao: 'Número sequencial da venda' },
{ chave: 'valor', descricao: 'Valor total da venda, formatado como "R$ 199,90"' },
],
lembrete_vencimento: [
{ chave: 'nome', descricao: 'Nome fantasia (ou razão social) do customer' },
{ chave: 'valor', descricao: 'Valor da parcela, formatado como "R$ 199,90"' },
{ chave: 'vencimento', descricao: 'Data de vencimento da parcela, formato dd/mm/aaaa' },
],
};
/** Espelha o model `alert_templates` (sempre `tenant_id: null` em `/master`). */
export interface AlertTemplate {
id: string;
tenant_id: string | null;
tipo: TipoTemplate;
canal: CanalTemplate;
assunto: string | null;
corpo: string;
ativo: boolean;
criado_em: string;
}
/** Espelha `CreateMasterAlertTemplateDto`/`UpdateMasterAlertTemplateDto`. */
export interface AlertTemplateInput {
tipo: TipoTemplate;
canal: CanalTemplate;
/** Obrigatório só quando `canal === 'email'`. */
assunto?: string;
corpo: string;
ativo?: boolean;
}
export interface ListAlertTemplatesParams {
tipo?: TipoTemplate;
canal?: CanalTemplate;
ativo?: boolean;
page?: number;
limit?: number;
}
export function listAlertTemplates(params: ListAlertTemplatesParams) {
return apiClient.get<PaginatedResult<AlertTemplate>>(
`/master/alertas/templates${buildQuery(params)}`,
);
}
export function createAlertTemplate(input: AlertTemplateInput) {
return apiClient.post<AlertTemplate>('/master/alertas/templates', input);
}
export function updateAlertTemplate(id: string, input: Partial<AlertTemplateInput>) {
return apiClient.put<AlertTemplate>(`/master/alertas/templates/${id}`, input);
}
/** Soft delete no backend (`ativo: false`) — não remove o registro. */
export function deleteAlertTemplate(id: string) {
return apiClient.delete<AlertTemplate>(`/master/alertas/templates/${id}`);
}
+16
View File
@@ -0,0 +1,16 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
/**
* `logo_url` (tenants) volta do backend como caminho relativo servido
* estaticamente (`/uploads/...`, ver `LocalStorageService`/Etapa 8) — como o
* backend roda numa origem diferente do frontend (`NEXT_PUBLIC_API_URL`,
* ex: `:3000` vs `:3001`), passar o caminho relativo direto para `<Image
* src>`/`<img src>` resolve contra a origem errada (a do próprio frontend).
* Resolve para URL absoluta contra o backend; deixa URLs já absolutas como
* estão (defensivo, caso isso mude no futuro).
*/
export function resolveAssetUrl(path: string | null | undefined): string | null {
if (!path) return null;
if (/^https?:\/\//.test(path)) return path;
return `${API_URL}${path.startsWith('/') ? path : `/${path}`}`;
}
+115
View File
@@ -0,0 +1,115 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly error?: string,
) {
super(message);
this.name = 'ApiError';
}
}
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: unknown;
};
/**
* Gancho de autenticação, preenchido pela Etapa 13. Antes da Etapa 13
* existir, o client simplesmente não tem access token para anexar e não
* tenta renovar sessão em respostas 401.
*/
export const authInterceptor: {
getAccessToken: () => string | null;
onUnauthorized: (() => Promise<boolean>) | null;
} = {
getAccessToken: () => null,
onUnauthorized: null,
};
async function parseErrorResponse(response: Response): Promise<ApiError> {
let body: { message?: string | string[]; error?: string } = {};
try {
body = await response.json();
} catch {
// corpo não é JSON (ex: erro de proxy/gateway) — segue com mensagem genérica
}
const message = Array.isArray(body.message)
? body.message.join(', ')
: (body.message ?? response.statusText ?? 'Erro inesperado');
return new ApiError(response.status, message, body.error);
}
async function request<T>(path: string, options: RequestOptions = {}, isRetry = false): Promise<T> {
const { body, headers, ...rest } = options;
const accessToken = authInterceptor.getAccessToken();
// `FormData` (ex: upload de logo, Etapa 18) precisa ir sem `Content-Type`
// explícito — o browser define `multipart/form-data; boundary=...`
// sozinho — e sem `JSON.stringify` (quebraria o corpo binário).
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
const response = await fetch(`${API_URL}${path}`, {
...rest,
credentials: 'include',
headers: {
...(body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...headers,
},
body: body === undefined ? undefined : isFormData ? (body as FormData) : JSON.stringify(body),
});
if (response.status === 401 && !isRetry && authInterceptor.onUnauthorized) {
const recovered = await authInterceptor.onUnauthorized();
if (recovered) {
return request<T>(path, options, true);
}
}
if (!response.ok) {
throw await parseErrorResponse(response);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export const apiClient = {
get: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'GET' }),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'POST', body }),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'PUT', body }),
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'PATCH', body }),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'DELETE' }),
};
/**
* Chamada "crua" ao `/auth/refresh`, fora do `request()`/`authInterceptor`
* de propósito: é ela quem *alimenta* `authInterceptor.onUnauthorized`, e
* se passasse pelo mesmo caminho, um 401 na própria renovação chamaria o
* interceptor de novo → recursão infinita. Usa só o cookie httpOnly
* (`credentials: 'include'`), sem access token.
*/
export async function fetchRefreshToken(): Promise<{ access_token: string } | null> {
try {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!response.ok) return null;
return (await response.json()) as { access_token: string };
} catch {
return null;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
/** Espelha o model `customers`. */
export interface Customer {
id: string;
razao_social: string;
nome_fantasia: string | null;
documento: string | null;
email: string | null;
telefone_whatsapp: string | null;
endereco: string | null;
cidade: string | null;
uf: string | null;
observacoes: string | null;
ativo: boolean;
criado_em: string;
atualizado_em: string;
}
/** `GET /tenant/customers/:id` inclui os contatos. */
export interface CustomerDetail extends Customer {
contatos: CustomerContact[];
}
/** Espelha `CreateCustomerDto`/`UpdateCustomerDto` — `ativo` fica de fora de propósito (só via desativar). */
export interface CustomerInput {
razao_social: string;
nome_fantasia?: string;
documento?: string;
email?: string;
telefone_whatsapp?: string;
endereco?: string;
cidade?: string;
uf?: string;
observacoes?: string;
}
export interface ListCustomersParams {
busca?: string;
ativo?: boolean;
page?: number;
limit?: number;
}
export function listCustomers(params: ListCustomersParams) {
return apiClient.get<PaginatedResult<Customer>>(`/tenant/customers${buildQuery(params)}`);
}
export function getCustomer(id: string) {
return apiClient.get<CustomerDetail>(`/tenant/customers/${id}`);
}
export function createCustomer(input: CustomerInput) {
return apiClient.post<Customer>('/tenant/customers', input);
}
export function updateCustomer(id: string, input: Partial<CustomerInput>) {
return apiClient.put<Customer>(`/tenant/customers/${id}`, input);
}
/** Soft delete (`ativo: false`). */
export function deactivateCustomer(id: string) {
return apiClient.delete<Customer>(`/tenant/customers/${id}`);
}
/** Espelha o model `customer_contacts`. */
export interface CustomerContact {
id: string;
customer_id: string;
nome: string;
cargo: string | null;
email: string | null;
telefone_whatsapp: string | null;
principal: boolean;
criado_em: string;
}
/** Espelha `CreateContactDto`/`UpdateContactDto`. */
export interface CustomerContactInput {
nome: string;
cargo?: string;
email?: string;
telefone_whatsapp?: string;
principal?: boolean;
}
export function listCustomerContacts(customerId: string) {
return apiClient.get<CustomerContact[]>(`/tenant/customers/${customerId}/contatos`);
}
export function createCustomerContact(customerId: string, input: CustomerContactInput) {
return apiClient.post<CustomerContact>(`/tenant/customers/${customerId}/contatos`, input);
}
export function updateCustomerContact(
customerId: string,
contactId: string,
input: Partial<CustomerContactInput>,
) {
return apiClient.put<CustomerContact>(`/tenant/customers/${customerId}/contatos/${contactId}`, input);
}
/** Hard delete. */
export function deleteCustomerContact(customerId: string, contactId: string) {
return apiClient.delete<void>(`/tenant/customers/${customerId}/contatos/${contactId}`);
}
+20
View File
@@ -0,0 +1,20 @@
import { apiClient } from '@/lib/api/client';
import type { TenantStatus } from '@/lib/api/tenants';
/**
* `GET /master/dashboard` — só o snapshot atual (`dashboard.service.ts` no
* backend não calcula nenhuma série histórica). Sem campo de evolução
* mensal, o gráfico Recharts previsto na tarefa 1 da Etapa 15 não é
* possível sem alterar o backend; esta etapa fica só com os cards de
* resumo, como a própria tarefa já autorizava nesse cenário.
*/
export interface MasterDashboardSummary {
tenants_por_status: Record<TenantStatus, number>;
mrr: string;
total_recebido_mes: string;
total_em_aberto: string;
}
export function getMasterDashboard() {
return apiClient.get<MasterDashboardSummary>('/master/dashboard');
}
+46
View File
@@ -0,0 +1,46 @@
import { apiClient } from '@/lib/api/client';
/** Espelha o retorno de `TenantEmailConfigService#obter` — dois formatos possíveis conforme `usar_config_padrao`. */
export interface TenantEmailConfig {
usar_config_padrao: boolean;
smtp_host?: string;
smtp_porta?: number;
smtp_usuario?: string;
/** Mascarada (`maskSecret`) — nunca o valor real. */
smtp_senha?: string;
email_remetente?: string;
}
/** Espelha `UpdateTenantEmailConfigDto`. */
export interface UpdateTenantEmailConfigInput {
usar_config_padrao: boolean;
smtp_host?: string;
smtp_porta?: number;
smtp_usuario?: string;
smtp_senha?: string;
email_remetente?: string;
}
export function getTenantEmailConfig() {
return apiClient.get<TenantEmailConfig>('/tenant/email-config');
}
export function updateTenantEmailConfig(input: UpdateTenantEmailConfigInput) {
return apiClient.put<TenantEmailConfig>('/tenant/email-config', input);
}
/**
* Espelha `DispatchResult` (`src/modules/alert-dispatch/alert-dispatch.service.ts`):
* o endpoint sempre responde HTTP 200 — falha de envio (SMTP indisponível,
* config incompleta etc) vem como `{ sucesso: false, erro }`, não como erro
* HTTP. Quem chama precisa checar `sucesso` explicitamente, não só o status
* da resposta.
*/
export interface TestarEnvioResult {
sucesso: boolean;
erro?: string;
}
export function testarEnvioEmail(destinatario: string) {
return apiClient.post<TestarEnvioResult>('/tenant/email-config/testar-envio', { destinatario });
}
+53
View File
@@ -0,0 +1,53 @@
import { apiClient } from '@/lib/api/client';
import type { UserRole } from '@/lib/auth/jwt';
/** Tupla `as const` (não `UserRole[]`) para preservar os literais na inferência do `z.enum` (`lib/validators/equipe.ts`). */
export const EQUIPE_ROLES = ['tenant_admin', 'vendedor', 'financeiro', 'visualizador'] as const;
export const EQUIPE_ROLE_LABEL: Record<UserRole, string> = {
tenant_admin: 'Administrador',
vendedor: 'Vendedor',
financeiro: 'Financeiro',
visualizador: 'Visualizador',
};
/** Espelha o `select` público de `EquipeService` (`src/modules/equipe/equipe.service.ts`). */
export interface EquipeUser {
id: string;
nome: string;
email: string;
role: UserRole;
ativo: boolean;
criado_em: string;
}
/** `senha_provisoria` só aparece nesta resposta, uma única vez, em texto puro. */
export interface EquipeUserCreated extends EquipeUser {
senha_provisoria: string;
}
/** Espelha `CreateEquipeUserDto`. */
export interface CreateEquipeUserInput {
nome: string;
email: string;
role: UserRole;
senha?: string;
}
/** Espelha `UpdateEquipeUserDto`. */
export interface UpdateEquipeUserInput {
role?: UserRole;
ativo?: boolean;
}
export function listEquipe() {
return apiClient.get<EquipeUser[]>('/tenant/equipe');
}
export function createEquipeUser(input: CreateEquipeUserInput) {
return apiClient.post<EquipeUserCreated>('/tenant/equipe', input);
}
export function updateEquipeUser(id: string, input: UpdateEquipeUserInput) {
return apiClient.patch<EquipeUser>(`/tenant/equipe/${id}`, input);
}
+55
View File
@@ -0,0 +1,55 @@
import { apiClient } from '@/lib/api/client';
/**
* Espelha o model `evolution_api_config`, já sanitizado pelo backend
* (`evolution.service.ts#sanitize`): nunca `api_key_cripto`, `api_key` vem
* sempre mascarada (`'*'.repeat(8) + últimos 4 caracteres`, nunca em texto
* completo — critério de aceite da Etapa 15).
*/
export interface EvolutionConfig {
id: string;
nome: string;
base_url: string;
api_key: string;
versao: string;
ativo: boolean;
criado_em: string;
}
/** Espelha `CreateEvolutionConfigDto`/`UpdateEvolutionConfigDto`. */
export interface EvolutionConfigInput {
nome: string;
base_url: string;
/** Opcional na edição — vazio significa "manter a chave atual" (nunca reenviar a mascarada). */
api_key?: string;
versao?: string;
}
export interface TestarConexaoResult {
status_http: number;
versao?: string;
mensagem?: string;
}
export function listEvolutionConfigs() {
return apiClient.get<EvolutionConfig[]>('/master/evolution-config');
}
export function createEvolutionConfig(input: EvolutionConfigInput & { api_key: string }) {
return apiClient.post<EvolutionConfig>('/master/evolution-config', input);
}
export function updateEvolutionConfig(
id: string,
input: Partial<EvolutionConfigInput> & { ativo?: boolean },
) {
return apiClient.put<EvolutionConfig>(`/master/evolution-config/${id}`, input);
}
export function deleteEvolutionConfig(id: string) {
return apiClient.delete<void>(`/master/evolution-config/${id}`);
}
export function testarConexaoEvolution(id: string) {
return apiClient.post<TestarConexaoResult>(`/master/evolution-config/${id}/testar-conexao`);
}
+57
View File
@@ -0,0 +1,57 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
export type FaturaStatus = 'pendente' | 'pago' | 'atrasado' | 'cancelado';
/** Espelha o model `tenant_faturas`. */
export interface Fatura {
id: string;
tenant_id: string;
referencia: string;
valor: string;
vencimento: string;
pago_em: string | null;
status: FaturaStatus;
alerta_enviado_em: string | null;
criado_em: string;
}
/** `GET /master/faturas` inclui um resumo do tenant em cada item. */
export interface FaturaListItem extends Fatura {
tenant: { id: string; razao_social: string };
}
export interface ListFaturasParams {
status?: FaturaStatus;
tenant_id?: string;
referencia?: string;
page?: number;
limit?: number;
}
export interface GerarFaturasMesResult {
referencia: string;
criadas: number;
ignoradas: number;
}
export interface AtualizarAtrasadasResult {
atualizadas: number;
tenants_afetados: number;
}
export function listFaturas(params: ListFaturasParams) {
return apiClient.get<PaginatedResult<FaturaListItem>>(`/master/faturas${buildQuery(params)}`);
}
export function gerarFaturasMes(referencia?: string) {
return apiClient.post<GerarFaturasMesResult>('/master/faturas/gerar-mes', referencia ? { referencia } : {});
}
export function marcarFaturaPaga(id: string) {
return apiClient.patch<Fatura>(`/master/faturas/${id}/marcar-pago`);
}
export function atualizarFaturasAtrasadas() {
return apiClient.post<AtualizarAtrasadasResult>('/master/faturas/atualizar-atrasadas');
}
+15
View File
@@ -0,0 +1,15 @@
import { apiClient } from '@/lib/api/client';
/** `GET /auth/master/admins` — lista completa, sem paginação nem filtro por usuário. */
export interface MasterAdmin {
id: string;
nome: string;
email: string;
ativo: boolean;
ultimo_login: string | null;
criado_em: string;
}
export function listMasterAdmins() {
return apiClient.get<MasterAdmin[]>('/auth/master/admins');
}
+50
View File
@@ -0,0 +1,50 @@
import { apiClient } from '@/lib/api/client';
/** Espelha o model `planos` (`prisma/schema.prisma`). */
export interface Plano {
id: string;
nome: string;
descricao: string | null;
valor_mensal: string;
limite_customers: number | null;
limite_usuarios: number | null;
limite_instancias_whatsapp: number;
ativo: boolean;
criado_em: string;
}
/** Espelha `CreatePlanoDto`/`UpdatePlanoDto` (`src/modules/planos/dto/`). */
export interface PlanoInput {
nome: string;
descricao?: string;
valor_mensal: number;
limite_customers?: number | null;
limite_usuarios?: number | null;
limite_instancias_whatsapp?: number;
}
/** `DELETE /master/planos/:id` — o backend converte em desativação se o plano estiver em uso. */
export interface DeletePlanoResult {
deletado: boolean;
desativado: boolean;
plano: Plano | null;
}
export function listPlanos(params?: { ativo?: boolean }) {
const qs = new URLSearchParams();
if (params?.ativo !== undefined) qs.set('ativo', String(params.ativo));
const query = qs.toString();
return apiClient.get<Plano[]>(`/master/planos${query ? `?${query}` : ''}`);
}
export function createPlano(input: PlanoInput) {
return apiClient.post<Plano>('/master/planos', input);
}
export function updatePlano(id: string, input: Partial<PlanoInput> & { ativo?: boolean }) {
return apiClient.put<Plano>(`/master/planos/${id}`, input);
}
export function deletePlano(id: string) {
return apiClient.delete<DeletePlanoResult>(`/master/planos/${id}`);
}
+61
View File
@@ -0,0 +1,61 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
export type ItemTipo = 'produto' | 'servico';
/** Espelha o model `products_services`. */
export interface ProductService {
id: string;
tipo: ItemTipo;
nome: string;
descricao: string | null;
sku: string | null;
valor_venda: string;
valor_custo: string | null;
unidade: string;
recorrente: boolean;
ativo: boolean;
criado_em: string;
atualizado_em: string;
}
/** Espelha `CreateProductDto`/`UpdateProductDto` — `ativo` fica de fora (só via desativar). */
export interface ProductInput {
tipo: ItemTipo;
nome: string;
descricao?: string;
sku?: string;
valor_venda: number;
valor_custo?: number;
unidade?: string;
recorrente?: boolean;
}
export interface ListProductsParams {
tipo?: ItemTipo;
busca?: string;
ativo?: boolean;
page?: number;
limit?: number;
}
export function listProducts(params: ListProductsParams) {
return apiClient.get<PaginatedResult<ProductService>>(`/tenant/produtos${buildQuery(params)}`);
}
export function getProduct(id: string) {
return apiClient.get<ProductService>(`/tenant/produtos/${id}`);
}
export function createProduct(input: ProductInput) {
return apiClient.post<ProductService>('/tenant/produtos', input);
}
export function updateProduct(id: string, input: Partial<ProductInput>) {
return apiClient.put<ProductService>(`/tenant/produtos/${id}`, input);
}
/** Soft delete (`ativo: false`). */
export function deactivateProduct(id: string) {
return apiClient.delete<ProductService>(`/tenant/produtos/${id}`);
}
+35
View File
@@ -0,0 +1,35 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
import type { FormaPagamento, ParcelaStatus, SalePayment } from '@/lib/api/sales';
export type { FormaPagamento } from '@/lib/api/sales';
/** `GET /tenant/recebimentos` inclui um resumo da venda/cliente em cada parcela. */
export interface Recebimento extends SalePayment {
sale: { id: string; numero_venda: number; customer_id: string; customer: { razao_social: string } };
}
export interface ListRecebimentosParams {
status?: ParcelaStatus;
customer_id?: string;
vencimento_inicio?: string;
vencimento_fim?: string;
page?: number;
limit?: number;
}
export function listRecebimentos(params: ListRecebimentosParams) {
return apiClient.get<PaginatedResult<Recebimento>>(`/tenant/recebimentos${buildQuery(params)}`);
}
export function marcarRecebimentoPago(id: string, forma_pagamento: FormaPagamento) {
return apiClient.patch<SalePayment>(`/tenant/recebimentos/${id}/marcar-pago`, { forma_pagamento });
}
export interface AtualizarAtrasadasResult {
atualizadas: number;
}
export function atualizarRecebimentosAtrasados() {
return apiClient.post<AtualizarAtrasadasResult>('/tenant/recebimentos/atualizar-atrasadas');
}
+127
View File
@@ -0,0 +1,127 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
import type { ProductService } from '@/lib/api/products';
export type VendaStatus = 'orcamento' | 'confirmada' | 'cancelada' | 'concluida';
export type ParcelaStatus = 'pendente' | 'pago' | 'atrasado' | 'cancelado';
export type FormaPagamento = 'pix' | 'boleto' | 'cartao' | 'dinheiro';
/** Transições válidas (espelha `TRANSICOES_VALIDAS`, `src/modules/sales/sales.service.ts`). */
export const TRANSICOES_VALIDAS: Record<VendaStatus, VendaStatus[]> = {
orcamento: ['confirmada', 'cancelada'],
confirmada: ['concluida', 'cancelada'],
concluida: [],
cancelada: [],
};
/** Espelha o model `sale_payments`. */
export interface SalePayment {
id: string;
sale_id: string;
numero_parcela: number;
valor: string;
vencimento: string;
pago_em: string | null;
forma_pagamento: string | null;
status: ParcelaStatus;
alerta_enviado_em: string | null;
criado_em: string;
}
/** Espelha o model `sale_items` + `include: { produto: true }`. */
export interface SaleItem {
id: string;
sale_id: string;
product_service_id: string;
quantidade: string;
valor_unitario: string;
valor_total: string;
produto: ProductService;
}
/**
* `SALE_INCLUDE` do backend é usado tanto em `findAll` quanto em
* `findOne` — listagem e detalhe têm exatamente o mesmo shape (itens com
* produto, parcelas ordenadas, customer resumido).
*/
export interface SaleListItem {
id: string;
tenant_id: string;
customer_id: string;
vendedor_id: string | null;
numero_venda: number;
status: VendaStatus;
valor_total: string;
desconto: string;
observacoes: string | null;
data_venda: string;
criado_em: string;
atualizado_em: string;
customer: { id: string; razao_social: string };
itens: SaleItem[];
parcelas: SalePayment[];
}
export interface ListSalesParams {
status?: VendaStatus;
customer_id?: string;
data_inicio?: string;
data_fim?: string;
page?: number;
limit?: number;
}
export function listSales(params: ListSalesParams) {
return apiClient.get<PaginatedResult<SaleListItem>>(`/tenant/vendas${buildQuery(params)}`);
}
export function getSale(id: string) {
return apiClient.get<SaleListItem>(`/tenant/vendas/${id}`);
}
export interface SaleItemInput {
product_service_id: string;
quantidade: number;
/** Se omitido, usa o `valor_venda` do catálogo. */
valor_unitario?: number;
}
export interface SaleParcelaInput {
valor: number;
vencimento: string;
}
/**
* Espelha `CreateSaleDto`/`UpdateSaleDto`. `numero_parcelas` OU `parcelas`
* — nunca os dois, nunca nenhum (validado em runtime pelo backend, não
* pelo DTO — o schema zod do frontend replica essa regra antes de bater
* na API).
*/
export interface SaleInput {
customer_id: string;
data_venda?: string;
desconto?: number;
observacoes?: string;
itens: SaleItemInput[];
numero_parcelas?: number;
parcelas?: SaleParcelaInput[];
}
export function createSale(input: SaleInput & { status?: 'orcamento' | 'confirmada' }) {
return apiClient.post<SaleListItem>('/tenant/vendas', input);
}
/** Só permitido com a venda em `orcamento` — o backend redefine itens/parcelas por inteiro. */
export function updateSale(id: string, input: SaleInput) {
return apiClient.put<SaleListItem>(`/tenant/vendas/${id}`, input);
}
/**
* Retorna a `sales` "crua" (sem `itens`/`parcelas`/`customer` populados,
* diferente de `create`/`update`/`getSale`) — por isso as telas sempre
* re-buscam via `getSale` depois de mudar o status, em vez de usar a
* resposta direto.
*/
export function changeSaleStatus(id: string, status: Exclude<VendaStatus, 'orcamento'>) {
return apiClient.patch<{ id: string; status: VendaStatus }>(`/tenant/vendas/${id}/status`, { status });
}
+21
View File
@@ -0,0 +1,21 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
import type { AlertLog, AlertLogStatus } from '@/lib/api/alert-logs';
import type { CanalTemplate } from '@/lib/api/alert-templates';
export { ALERT_LOG_STATUSES } from '@/lib/api/alert-logs';
export type { AlertLog, AlertLogStatus } from '@/lib/api/alert-logs';
export interface ListTenantAlertLogsParams {
canal?: CanalTemplate;
status?: AlertLogStatus;
data_inicio?: string;
data_fim?: string;
page?: number;
limit?: number;
}
/** `GET /tenant/alertas/logs` — já filtrado pelo tenant da sessão (RLS/contexto), sem parâmetro `tenant_id`. */
export function listTenantAlertLogs(params: ListTenantAlertLogsParams) {
return apiClient.get<PaginatedResult<AlertLog>>(`/tenant/alertas/logs${buildQuery(params)}`);
}
@@ -0,0 +1,69 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
import {
TIPO_TEMPLATE_LABEL,
TIPOS_TEMPLATE,
type AlertTemplate,
type CanalTemplate,
type TipoTemplate,
} from '@/lib/api/alert-templates';
export {
CANAIS_TEMPLATE,
CANAL_TEMPLATE_LABEL,
PLACEHOLDERS_POR_TIPO,
TIPO_TEMPLATE_LABEL,
} from '@/lib/api/alert-templates';
export type { AlertTemplate, CanalTemplate, TipoTemplate } from '@/lib/api/alert-templates';
/**
* `mensalidade_tenant` fica de fora — é o master notificando o tenant sobre
* a própria mensalidade, o tenant não define esse template (espelha
* `TIPOS_TEMPLATE_TENANT` em `src/modules/alerts/alert-template-tipos.ts`).
*/
export const TIPOS_TEMPLATE_TENANT = TIPOS_TEMPLATE.filter(
(tipo) => tipo !== 'mensalidade_tenant',
);
export type TipoTemplateTenant = (typeof TIPOS_TEMPLATE_TENANT)[number];
export const TIPO_TEMPLATE_TENANT_LABEL: Record<TipoTemplateTenant, string> =
Object.fromEntries(
TIPOS_TEMPLATE_TENANT.map((tipo) => [tipo, TIPO_TEMPLATE_LABEL[tipo]]),
) as Record<TipoTemplateTenant, string>;
/** Espelha `CreateTenantAlertTemplateDto`/`UpdateTenantAlertTemplateDto`. */
export interface TenantAlertTemplateInput {
tipo: TipoTemplateTenant;
canal: CanalTemplate;
/** Obrigatório só quando `canal === 'email'`. */
assunto?: string;
corpo: string;
ativo?: boolean;
}
export interface ListTenantAlertTemplatesParams {
tipo?: TipoTemplate;
canal?: CanalTemplate;
ativo?: boolean;
page?: number;
limit?: number;
}
export function listTenantAlertTemplates(params: ListTenantAlertTemplatesParams) {
return apiClient.get<PaginatedResult<AlertTemplate>>(
`/tenant/alertas/templates${buildQuery(params)}`,
);
}
export function createTenantAlertTemplate(input: TenantAlertTemplateInput) {
return apiClient.post<AlertTemplate>('/tenant/alertas/templates', input);
}
export function updateTenantAlertTemplate(id: string, input: Partial<TenantAlertTemplateInput>) {
return apiClient.put<AlertTemplate>(`/tenant/alertas/templates/${id}`, input);
}
/** Soft delete no backend (`ativo: false`) — não remove o registro. */
export function deleteTenantAlertTemplate(id: string) {
return apiClient.delete<AlertTemplate>(`/tenant/alertas/templates/${id}`);
}
+36
View File
@@ -0,0 +1,36 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery } from '@/lib/api/types';
import type { SalePayment } from '@/lib/api/sales';
export interface ResumoSemanalRow {
semana: string;
qtd_vendas: number;
total_vendido: number;
}
export interface ResumoMensalRow {
mes: string;
qtd_vendas: number;
total_vendido: number;
}
export interface TenantDashboardFinanceiro {
total_recebido_mes: string;
total_a_receber: string;
total_atrasado: string;
proximas_parcelas: (SalePayment & {
sale: { numero_venda: number; customer: { razao_social: string } };
})[];
}
export function getResumoSemanal(params?: { inicio?: string; fim?: string }) {
return apiClient.get<ResumoSemanalRow[]>(`/tenant/dashboard/resumo-semanal${buildQuery(params ?? {})}`);
}
export function getResumoMensal(params?: { inicio?: string; fim?: string }) {
return apiClient.get<ResumoMensalRow[]>(`/tenant/dashboard/resumo-mensal${buildQuery(params ?? {})}`);
}
export function getFinanceiro(limite = 5) {
return apiClient.get<TenantDashboardFinanceiro>(`/tenant/dashboard/financeiro${buildQuery({ limite })}`);
}
+37
View File
@@ -0,0 +1,37 @@
import { apiClient } from '@/lib/api/client';
/** Espelha o retorno de `GET/PUT /tenant/perfil`. */
export interface TenantProfile {
logo_url: string | null;
cor_primaria: string | null;
nome_fantasia: string | null;
razao_social: string | null;
}
/** Espelha `UpdatePerfilDto` — ambos os campos opcionais, atualização parcial. */
export interface UpdateTenantProfileInput {
nome_fantasia?: string;
razao_social?: string;
}
export function getTenantProfile() {
return apiClient.get<TenantProfile>('/tenant/perfil');
}
export function updateTenantProfile(input: UpdateTenantProfileInput) {
return apiClient.put<TenantProfile>('/tenant/perfil', input);
}
export function updateTenantCor(cor_primaria: string) {
return apiClient.put<{ cor_primaria: string }>('/tenant/perfil/cor', { cor_primaria });
}
export function uploadTenantLogo(file: File) {
const formData = new FormData();
formData.append('logo', file);
return apiClient.post<{ logo_url: string }>('/tenant/perfil/logo', formData);
}
export function removeTenantLogo() {
return apiClient.delete<{ logo_url: string | null }>('/tenant/perfil/logo');
}
+97
View File
@@ -0,0 +1,97 @@
import { apiClient } from '@/lib/api/client';
import { buildQuery, type PaginatedResult } from '@/lib/api/types';
import type { Fatura } from '@/lib/api/faturas';
export type TenantStatus = 'trial' | 'ativo' | 'inadimplente' | 'suspenso' | 'cancelado';
/** `trial` só é atribuído automaticamente na criação — nunca é um destino válido de transição manual. */
export type TenantStatusTransition = Exclude<TenantStatus, 'trial'>;
interface TenantPlanoSummary {
id: string;
nome: string;
valor_mensal: string;
}
/** Espelha o model `tenants`, versão de listagem (`plano` selecionado, sem `faturas`/`_count`). */
export interface TenantListItem {
id: string;
razao_social: string;
nome_fantasia: string | null;
documento: string | null;
email_contato: string;
telefone: string | null;
logo_url: string | null;
cor_primaria: string | null;
plano_id: string;
status: TenantStatus;
dia_vencimento_mensalidade: number;
trial_expira_em: string | null;
criado_em: string;
atualizado_em: string;
plano: TenantPlanoSummary;
}
/** `GET /master/tenants/:id` — inclui as últimas 5 faturas e contagem de uso. */
export interface TenantDetail extends Omit<TenantListItem, 'plano'> {
plano: TenantPlanoSummary & { limite_customers: number | null; limite_usuarios: number | null };
faturas: Fatura[];
_count: { usuarios: number; customers: number; vendas: number };
}
export interface TenantAdminCreated {
id: string;
nome: string;
email: string;
senha_provisoria: string;
}
export interface CreateTenantResult {
tenant: TenantListItem;
admin: TenantAdminCreated;
}
/** Espelha `CreateTenantDto` (`src/modules/tenants/dto/create-tenant.dto.ts`). */
export interface CreateTenantInput {
razao_social: string;
nome_fantasia?: string;
documento?: string;
email_contato: string;
telefone?: string;
plano_id: string;
dia_vencimento_mensalidade?: number;
admin: {
nome: string;
email: string;
senha?: string;
};
}
/** Espelha `UpdateTenantDto` — mesmos campos cadastrais, sem `admin`/`status`. */
export type UpdateTenantInput = Partial<Omit<CreateTenantInput, 'admin'>>;
export interface ListTenantsParams {
status?: TenantStatus;
busca?: string;
page?: number;
limit?: number;
}
export function listTenants(params: ListTenantsParams) {
return apiClient.get<PaginatedResult<TenantListItem>>(`/master/tenants${buildQuery(params)}`);
}
export function getTenant(id: string) {
return apiClient.get<TenantDetail>(`/master/tenants/${id}`);
}
export function createTenant(input: CreateTenantInput) {
return apiClient.post<CreateTenantResult>('/master/tenants', input);
}
export function updateTenant(id: string, input: UpdateTenantInput) {
return apiClient.put<TenantListItem>(`/master/tenants/${id}`, input);
}
export function updateTenantStatus(id: string, status: TenantStatusTransition) {
return apiClient.patch<TenantListItem>(`/master/tenants/${id}/status`, { status });
}
+15
View File
@@ -0,0 +1,15 @@
/** Formato comum de toda listagem paginada da API (`{ data, meta }`). */
export interface PaginatedResult<T> {
data: T[];
meta: { total: number; page: number; limit: number };
}
/** Aceita qualquer objeto de filtros (interfaces nomeadas não têm índice `string` implícito). */
export function buildQuery(params: object): string {
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params as Record<string, string | number | undefined>)) {
if (value !== undefined && value !== '') qs.set(key, String(value));
}
const query = qs.toString();
return query ? `?${query}` : '';
}
+74
View File
@@ -0,0 +1,74 @@
import { apiClient } from '@/lib/api/client';
export const WHATSAPP_INSTANCE_STATUSES = [
'desconectado',
'conectando',
'conectado',
'erro',
] as const;
export type WhatsappInstanceStatus = (typeof WHATSAPP_INSTANCE_STATUSES)[number];
/** Espelha o model `tenant_whatsapp_instances`. */
export interface WhatsappInstance {
id: string;
tenant_id: string;
evolution_config_id: string | null;
instance_name: string;
numero_conectado: string | null;
status: WhatsappInstanceStatus;
qr_code_gerado_em: string | null;
criado_em: string;
}
/**
* Espelha `EvolutionQrCode` — por comentário do próprio backend
* (`evolution-api.client.ts`), o prefixo exato de `base64` não é garantido
* (é o que a Evolution API real devolveu, repassado como veio). Renderizar
* defensivamente: se já começar com `data:`, usar como está; senão,
* prefixar `data:image/png;base64,`.
*/
export interface WhatsappQrCode {
base64?: string;
pairing_code?: string;
}
export interface TestarEnvioWhatsappInput {
numero: string;
mensagem?: string;
}
export function listWhatsappInstances() {
return apiClient.get<WhatsappInstance[]>('/tenant/whatsapp/instancias');
}
export function criarWhatsappInstance() {
return apiClient.post<WhatsappInstance>('/tenant/whatsapp/instancias');
}
export function obterWhatsappQrCode(id: string) {
return apiClient.get<WhatsappQrCode>(`/tenant/whatsapp/instancias/${id}/qrcode`);
}
export function consultarWhatsappStatus(id: string) {
return apiClient.get<WhatsappInstance>(`/tenant/whatsapp/instancias/${id}/status`);
}
export function removerWhatsappInstance(id: string) {
return apiClient.delete<void>(`/tenant/whatsapp/instancias/${id}`);
}
/**
* Espelha `DispatchResult` (`src/modules/alert-dispatch/alert-dispatch.service.ts`):
* o endpoint sempre responde HTTP 200 — falha de envio (instância não
* conectada, Evolution API indisponível etc) vem como
* `{ sucesso: false, erro }`, não como erro HTTP. Quem chama precisa checar
* `sucesso` explicitamente, não só o status da resposta.
*/
export interface TestarEnvioResult {
sucesso: boolean;
erro?: string;
}
export function testarEnvioWhatsapp(id: string, input: TestarEnvioWhatsappInput) {
return apiClient.post<TestarEnvioResult>(`/tenant/whatsapp/instancias/${id}/testar-envio`, input);
}
+130
View File
@@ -0,0 +1,130 @@
'use client';
import * as React from 'react';
import { apiClient, authInterceptor, fetchRefreshToken } from '@/lib/api/client';
import { clearSessionCookie, setSessionCookie } from '@/lib/auth/session-cookie';
import { decodeJwt, type JwtPayload } from '@/lib/auth/jwt';
interface LoginResponse {
access_token: string;
}
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
interface AuthContextValue {
status: AuthStatus;
user: JwtPayload | null;
loginMaster: (email: string, senha: string) => Promise<void>;
loginTenant: (email: string, senha: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = React.createContext<AuthContextValue | null>(null);
/**
* Fonte única de sessão do app (Etapa 13). Access token só existe em
* memória (`tokenRef`/estado do React) — nunca em `localStorage`. O
* refresh token real vive só no cookie httpOnly do backend; este
* Provider nunca o lê nem o guarda, só dispara `POST /auth/refresh` (que
* o browser já anexa o cookie automaticamente) e reage ao resultado.
*/
export function AuthProvider({ children }: { children: React.ReactNode }) {
const tokenRef = React.useRef<string | null>(null);
const [status, setStatus] = React.useState<AuthStatus>('loading');
const [user, setUser] = React.useState<JwtPayload | null>(null);
// Deduplica chamadas concorrentes a `refreshSession` (ver comentário abaixo).
const refreshPromiseRef = React.useRef<Promise<boolean> | null>(null);
const applyToken = React.useCallback((token: string | null) => {
tokenRef.current = token;
setUser(token ? decodeJwt(token) : null);
setStatus(token ? 'authenticated' : 'unauthenticated');
}, []);
/**
* Duas fontes independentes podem chamar isto quase ao mesmo tempo logo
* após um reload de página protegida: o efeito de montagem deste
* Provider (recupera a sessão) e `authInterceptor.onUnauthorized`
* (disparado por um 401 de uma request que saiu antes do token estar
* pronto). Sem deduplicar, a 2ª chamada reenvia o mesmo cookie de
* refresh que a 1ª já rotacionou — o backend trata isso como reuso de
* token revogado e revoga a família inteira (Etapa 2), derrubando a
* sessão que a 1ª chamada acabou de validar. Compartilhar a mesma
* promise entre chamadas concorrentes evita a corrida.
*/
const refreshSession = React.useCallback((): Promise<boolean> => {
if (refreshPromiseRef.current) return refreshPromiseRef.current;
const promise = (async () => {
try {
const result = await fetchRefreshToken();
if (!result) {
applyToken(null);
clearSessionCookie();
return false;
}
applyToken(result.access_token);
return true;
} finally {
refreshPromiseRef.current = null;
}
})();
refreshPromiseRef.current = promise;
return promise;
}, [applyToken]);
// Gancho deixado pronto na Etapa 11: liga o client HTTP à sessão atual.
React.useEffect(() => {
authInterceptor.getAccessToken = () => tokenRef.current;
authInterceptor.onUnauthorized = refreshSession;
}, [refreshSession]);
// Recupera a sessão em memória após um reload de página (o access token
// não sobrevive a isso de propósito — só o cookie httpOnly sobrevive).
React.useEffect(() => {
void refreshSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loginMaster = React.useCallback(
async (email: string, senha: string) => {
const tokens = await apiClient.post<LoginResponse>('/auth/master/login', { email, senha });
applyToken(tokens.access_token);
setSessionCookie('master');
},
[applyToken],
);
const loginTenant = React.useCallback(
async (email: string, senha: string) => {
const tokens = await apiClient.post<LoginResponse>('/auth/tenant/login', { email, senha });
applyToken(tokens.access_token);
setSessionCookie('tenant');
},
[applyToken],
);
const logout = React.useCallback(async () => {
try {
await apiClient.post('/auth/logout', {});
} catch {
// mesmo se a chamada falhar (ex: rede), a sessão local é limpa de qualquer forma
}
applyToken(null);
clearSessionCookie();
}, [applyToken]);
const value = React.useMemo(
() => ({ status, user, loginMaster, loginTenant, logout }),
[status, user, loginMaster, loginTenant, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = React.useContext(AuthContext);
if (!ctx) throw new Error('useAuth precisa estar dentro de <AuthProvider>');
return ctx;
}
+25
View File
@@ -0,0 +1,25 @@
export type UserRole = 'tenant_admin' | 'vendedor' | 'financeiro' | 'visualizador';
export interface JwtPayload {
sub: string;
tipo: 'master' | 'tenant_user';
tenant_id: string | null;
role?: UserRole;
exp?: number;
}
/**
* Decodifica o payload do access token para ler identidade/papel no
* client. **Não valida assinatura nem expiração de verdade** — isso é
* responsabilidade exclusiva do backend em cada chamada de API; aqui é
* só para decisões de UI (qual área mostrar, quais itens de menu).
*/
export function decodeJwt(token: string): JwtPayload | null {
try {
const payload = token.split('.')[1];
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json) as JwtPayload;
} catch {
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
export type SessionArea = 'master' | 'tenant';
/**
* Cookie de sinalização para o middleware do Next.js (Etapa 13).
*
* O refresh token real fica em cookie httpOnly setado pelo **backend**,
* com `Path=/auth` — ou seja, só é enviado para o próprio backend, nunca
* para o Next.js. O middleware roda no servidor do frontend e não tem
* acesso a esse cookie (nem deveria: são origens diferentes). Por isso
* existe este segundo cookie, **não sensível** (só diz "há uma sessão
* ativa, tipo master/tenant"), setado pelo próprio frontend depois de um
* login bem-sucedido — só para o middleware decidir bloquear/redirecionar
* *antes* de renderizar a página (evita "flash" de conteúdo protegido).
*
* Isso é uma verificação otimista, não uma fronteira de segurança: a
* autorização de verdade acontece no backend, em toda chamada de API, via
* o access token real (que nunca fica em cookie, só em memória).
*/
const SESSION_COOKIE = 'sc_session';
const MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
export function setSessionCookie(area: SessionArea): void {
document.cookie = `${SESSION_COOKIE}=${area}; Path=/; Max-Age=${MAX_AGE_SECONDS}; SameSite=Lax`;
}
export function clearSessionCookie(): void {
document.cookie = `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`;
}
export function readSessionCookieValue(cookieHeader: string | undefined): SessionArea | null {
if (!cookieHeader) return null;
const match = cookieHeader.match(new RegExp(`${SESSION_COOKIE}=(master|tenant)`));
return (match?.[1] as SessionArea | undefined) ?? null;
}
export { SESSION_COOKIE };
+5
View File
@@ -0,0 +1,5 @@
/** UFs do Brasil — usado no Select de estado dos formulários de cliente. */
export const BR_STATES = [
'AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MT', 'MS', 'MG',
'PA', 'PB', 'PR', 'PE', 'PI', 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE', 'TO',
] as const;
+31
View File
@@ -0,0 +1,31 @@
/** Formatação compartilhada (moeda/data pt-BR) — Etapa 15 em diante. */
const currencyFormatter = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
});
const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' });
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
/** Aceita `string`/`number` (a API devolve `Decimal` do Prisma como string). */
export function formatCurrency(value: string | number | null | undefined): string {
if (value === null || value === undefined) return '—';
const numeric = typeof value === 'string' ? Number(value) : value;
if (Number.isNaN(numeric)) return '—';
return currencyFormatter.format(numeric);
}
export function formatDate(value: string | Date | null | undefined): string {
if (!value) return '—';
const date = typeof value === 'string' ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return '—';
return dateFormatter.format(date);
}
export function formatDateTime(value: string | Date | null | undefined): string {
if (!value) return '—';
const date = typeof value === 'string' ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return '—';
return dateTimeFormatter.format(date);
}
+81
View File
@@ -0,0 +1,81 @@
import {
LayoutDashboard,
Building2,
Package,
Receipt,
MessageSquare,
Bell,
Settings,
Users,
ShoppingCart,
Wallet,
Mail,
UsersRound,
UserRound,
type LucideIcon,
} from 'lucide-react';
import type { UserRole } from '@/lib/auth/jwt';
export interface NavItem {
href: string;
label: string;
icon: LucideIcon;
/** Sem `roles`, visível para qualquer papel autenticado da área. */
roles?: UserRole[];
/** Grupo expansível (ex: Alertas → Templates/Logs); `href` é o destino padrão. */
children?: { href: string; label: string }[];
}
export const MASTER_NAV_ITEMS: NavItem[] = [
{ href: '/master/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/master/tenants', label: 'Tenants', icon: Building2 },
{ href: '/master/planos', label: 'Planos', icon: Package },
{ href: '/master/faturas', label: 'Faturas', icon: Receipt },
{ href: '/master/evolution-config', label: 'Evolution API', icon: MessageSquare },
{
href: '/master/alertas/templates',
label: 'Alertas',
icon: Bell,
children: [
{ href: '/master/alertas/templates', label: 'Templates' },
{ href: '/master/alertas/logs', label: 'Logs' },
],
},
{ href: '/master/configuracoes', label: 'Configurações', icon: Settings },
];
/** 4 principais da bottom tab bar do master (Etapa 14) — o resto entra em "Mais". */
export const MASTER_BOTTOM_HREFS = [
'/master/dashboard',
'/master/tenants',
'/master/faturas',
'/master/alertas/templates',
];
export const TENANT_NAV_ITEMS: NavItem[] = [
{ href: '/app/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/app/clientes', label: 'Clientes', icon: Users },
{ href: '/app/produtos', label: 'Produtos', icon: Package },
{ href: '/app/vendas', label: 'Vendas', icon: ShoppingCart },
{ href: '/app/recebimentos', label: 'Recebimentos', icon: Wallet },
{ href: '/app/whatsapp', label: 'WhatsApp', icon: MessageSquare },
{ href: '/app/email-config', label: 'E-mail', icon: Mail },
{
href: '/app/alertas/templates',
label: 'Alertas',
icon: Bell,
children: [
{ href: '/app/alertas/templates', label: 'Templates' },
{ href: '/app/alertas/logs', label: 'Logs' },
],
},
{ href: '/app/equipe', label: 'Equipe', icon: UsersRound, roles: ['tenant_admin'] },
{ href: '/app/perfil', label: 'Perfil', icon: UserRound },
];
/** 4 principais da bottom tab bar do tenant (Etapa 14) — o resto entra em "Mais". */
export const TENANT_BOTTOM_HREFS = ['/app/dashboard', '/app/clientes', '/app/vendas', '/app/recebimentos'];
export function filterByRole(items: NavItem[], role: UserRole | undefined): NavItem[] {
return items.filter((item) => !item.roles || (role && item.roles.includes(role)));
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Preferência de sidebar colapsada/expandida (Etapa 14), não sensível.
* Lida no servidor (`app/master/layout.tsx`/`app/app/layout.tsx`, via
* `next/headers`) para renderizar já no estado certo sem "flash" de layout,
* e persistida no client a cada toggle — mesmo padrão de
* `lib/auth/session-cookie.ts` (Etapa 13).
*/
export const SIDEBAR_COOKIE = 'sc_sidebar_collapsed';
const MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
export function readSidebarCollapsedCookie(value: string | undefined): boolean {
return value === '1';
}
export function persistSidebarCollapsed(collapsed: boolean): void {
document.cookie = `${SIDEBAR_COOKIE}=${collapsed ? '1' : '0'}; Path=/; Max-Age=${MAX_AGE_SECONDS}; SameSite=Lax`;
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ApiError } from '@/lib/api/client';
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: (failureCount, error) => {
// Erros de negócio (400/401/403/404/409...) não se resolvem tentando de
// novo — só vale re-tentar falha de rede/infra, e só uma vez.
if (error instanceof ApiError) return false;
return failureCount < 1;
},
},
mutations: {
retry: false,
},
},
});
}
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = React.useState(createQueryClient);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Contraste WCAG AA (seção 6 do schema de arquitetura). Usado para
* garantir que a cor de marca de um tenant, quando usada como texto/ícone
* sobre fundo claro, continue legível — mesmo que o tenant tenha
* escolhido uma cor originalmente clara demais para isso.
*/
interface Rgb {
r: number;
g: number;
b: number;
}
function hexToRgb(hex: string): Rgb {
const normalized = hex.replace('#', '');
const full = normalized.length === 3 ? normalized.replace(/(.)/g, '$1$1') : normalized;
const value = parseInt(full, 16);
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
}
function rgbToHex({ r, g, b }: Rgb): string {
return `#${[r, g, b].map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join('')}`;
}
function relativeLuminance({ r, g, b }: Rgb): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const channel = c / 255;
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export function contrastRatio(hexA: string, hexB: string): number {
const lA = relativeLuminance(hexToRgb(hexA));
const lB = relativeLuminance(hexToRgb(hexB));
const lighter = Math.max(lA, lB);
const darker = Math.min(lA, lB);
return (lighter + 0.05) / (darker + 0.05);
}
const WCAG_AA_MIN_CONTRAST = 4.5;
/**
* Se `hex` já tem contraste AA (≥4.5:1) contra `background`, devolve ele
* mesmo. Senão, escurece progressivamente até passar (ou até chegar em
* preto). Não deixa a cor original para uso como fundo — só gera a
* variante seguro para texto/ícone.
*/
export function ensureAccessibleTextColor(hex: string, background = '#FFFFFF'): string {
if (contrastRatio(hex, background) >= WCAG_AA_MIN_CONTRAST) {
return hex;
}
const base = hexToRgb(hex);
for (let step = 1; step <= 20; step += 1) {
const factor = step / 20;
const darkened = rgbToHex({
r: Math.round(base.r * (1 - factor)),
g: Math.round(base.g * (1 - factor)),
b: Math.round(base.b * (1 - factor)),
});
if (contrastRatio(darkened, background) >= WCAG_AA_MIN_CONTRAST) {
return darkened;
}
}
return '#000000';
}
+32
View File
@@ -0,0 +1,32 @@
import { resolveAssetUrl } from '@/lib/api/asset-url';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
export interface TenantTheme {
nome: string;
logoUrl: string | null;
corPrimaria: string | null;
}
interface PublicTenantThemeResponse {
nome: string;
logo_url: string | null;
cor_primaria: string | null;
}
/**
* `GET /public/tenants/:id/tema` (Etapa 8 do backend) — sem autenticação,
* usado tanto antes do login (se o tenant for identificável, ver
* `lib/theme/theme-provider.tsx`) quanto depois (a partir do `tenant_id`
* do usuário autenticado).
*/
export async function fetchTenantTheme(tenantId: string): Promise<TenantTheme | null> {
try {
const response = await fetch(`${API_URL}/public/tenants/${tenantId}/tema`);
if (!response.ok) return null;
const data = (await response.json()) as PublicTenantThemeResponse;
return { nome: data.nome, logoUrl: resolveAssetUrl(data.logo_url), corPrimaria: data.cor_primaria };
} catch {
return null;
}
}
+109
View File
@@ -0,0 +1,109 @@
'use client';
import * as React from 'react';
import { fetchTenantTheme, type TenantTheme } from '@/lib/theme/resolve-theme';
import { ensureAccessibleTextColor } from '@/lib/theme/contrast';
interface TenantThemeContextValue {
theme: TenantTheme | null;
loading: boolean;
/**
* Atualiza o tema em runtime sem depender de um novo fetch (Etapa 18 —
* `/app/perfil`, critério de aceite "reflete instantaneamente sem
* reload"): aplica o patch tanto no estado (consumido por `TenantBrand`
* para nome/logo) quanto nas CSS variables de `:root` quando
* `corPrimaria` muda, mesma técnica de `ensureAccessibleTextColor` já
* usada na resolução inicial do tema.
*/
applyThemeUpdate: (patch: Partial<TenantTheme>) => void;
}
const TenantThemeContext = React.createContext<TenantThemeContextValue>({
theme: null,
loading: false,
applyThemeUpdate: () => {},
});
/**
* Resolve e aplica o tema do tenant (seção 6 do schema) via CSS variables
* em `:root`, com o ajuste de contraste da Etapa 13 embutido.
*
* `tenantId` é passado explicitamente pelo consumidor porque a origem
* muda conforme o contexto:
* - Pré-login (`/login`): só é conhecido se vier de `?tenant=<uuid>` na
* URL — o schema não tem `slug`/subdomínio (ver observações da Etapa
* 13 no controle de progresso), então sem esse parâmetro não há como
* identificar o tenant antes de autenticar, e o tema padrão da
* plataforma é usado (já é o fallback em `styles/tokens.css`).
* - Pós-login (`/app/*`, a partir da Etapa 14): `tenant_id` decodificado
* do access token do usuário autenticado.
*
* Sem `tenantId`, não faz nada — os valores padrão de `tokens.css`
* continuam valendo.
*/
export function TenantThemeProvider({
tenantId,
children,
}: {
tenantId: string | null;
children: React.ReactNode;
}) {
const [theme, setTheme] = React.useState<TenantTheme | null>(null);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!tenantId) {
document.documentElement.style.removeProperty('--color-primary');
document.documentElement.style.removeProperty('--color-primary-on-light');
setTheme(null);
return;
}
let cancelled = false;
setLoading(true);
fetchTenantTheme(tenantId).then((result) => {
if (cancelled) return;
setLoading(false);
setTheme(result);
if (result?.corPrimaria) {
document.documentElement.style.setProperty('--color-primary', result.corPrimaria);
document.documentElement.style.setProperty(
'--color-primary-on-light',
ensureAccessibleTextColor(result.corPrimaria),
);
}
});
return () => {
cancelled = true;
};
}, [tenantId]);
const applyThemeUpdate = React.useCallback((patch: Partial<TenantTheme>) => {
if (patch.corPrimaria) {
document.documentElement.style.setProperty('--color-primary', patch.corPrimaria);
document.documentElement.style.setProperty(
'--color-primary-on-light',
ensureAccessibleTextColor(patch.corPrimaria),
);
}
setTheme((prev) => ({
nome: patch.nome ?? prev?.nome ?? 'SeConectai Vendas',
logoUrl: patch.logoUrl !== undefined ? patch.logoUrl : (prev?.logoUrl ?? null),
corPrimaria: patch.corPrimaria ?? prev?.corPrimaria ?? null,
}));
}, []);
const value = React.useMemo(
() => ({ theme, loading, applyThemeUpdate }),
[theme, loading, applyThemeUpdate],
);
return <TenantThemeContext.Provider value={value}>{children}</TenantThemeContext.Provider>;
}
export function useTenantTheme(): TenantThemeContextValue {
return React.useContext(TenantThemeContext);
}
+1
View File
@@ -0,0 +1 @@
export { toast } from 'sonner';
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -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>;