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);
}