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