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,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)}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 })}`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}` : '';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { toast } from 'sonner';
|
||||
@@ -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>;
|
||||
@@ -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