fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
Auditoria de segurança + robustez da área do representante antes de produção. API (autorização e injeção): - IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos, histórico, contatos) validam carteira do rep via assertClienteDaCarteira; cliente alheio retorna 404. orders.create idem antes de aceitar idCliente. - Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403). - Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação); dtAniversario de contato validado e escapado. - id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded. - Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela pauta>promo>base + promoção ativa), não só o desconto global. - Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono. Web (erros nunca silenciosos): - Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e politicas — corrige criar oportunidade, abrir chamado e editar políticas. - Handler global de erro no QueryClient (query e mutation viram toast). - Error boundary por rota (payload divergente não derruba o app em branco). - Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis. Qualidade: - Corrige ping.contract.spec (idEmpresa) e zera erros de lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,12 @@ function escSql(s: string): string {
|
|||||||
return s.replace(/'/g, "''");
|
return s.replace(/'/g, "''");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para
|
||||||
|
// origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe.
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
// Row bruta do $queryRawUnsafe
|
// Row bruta do $queryRawUnsafe
|
||||||
interface ClientRow {
|
interface ClientRow {
|
||||||
id_cliente: number;
|
id_cliente: number;
|
||||||
@@ -108,6 +114,25 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
|
|||||||
export class ClientsService {
|
export class ClientsService {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: rep só acessa clientes da própria carteira; gestores passam direto.
|
||||||
|
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
|
||||||
|
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'rep') return;
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
idCliente,
|
||||||
|
);
|
||||||
|
if (!rows[0] || Number(rows[0].cod_vendedor) !== codVendedor) {
|
||||||
|
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
@@ -257,6 +282,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listContacts(idCorrent: number): Promise<Contato[]> {
|
async listContacts(idCorrent: number): Promise<Contato[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
@@ -316,6 +342,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
@@ -337,7 +364,7 @@ export class ClientsService {
|
|||||||
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
||||||
'${esc(dto.nome)}',
|
'${esc(dto.nome)}',
|
||||||
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
||||||
${dto.dtAniversario ? `'${dto.dtAniversario}'` : 'NULL'},
|
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
|
||||||
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
||||||
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
||||||
)
|
)
|
||||||
@@ -370,6 +397,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
@@ -430,12 +458,13 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
||||||
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
id_produto: number;
|
id_produto: number;
|
||||||
@@ -493,8 +522,10 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
qtd_aberto: string;
|
qtd_aberto: string;
|
||||||
@@ -513,6 +544,7 @@ export class ClientsService {
|
|||||||
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
||||||
FROM sar.vw_ctr
|
FROM sar.vw_ctr
|
||||||
WHERE id_cliente = ${idCliente}
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND id_empresa = ${idEmpresaMatriz}
|
||||||
AND situacao = 'A'
|
AND situacao = 'A'
|
||||||
AND saldo > 0
|
AND saldo > 0
|
||||||
`);
|
`);
|
||||||
@@ -528,6 +560,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findOne(idCliente: number): Promise<ClientDetail> {
|
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
@@ -579,8 +612,10 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
type CtrRow = {
|
type CtrRow = {
|
||||||
id_ctr: number;
|
id_ctr: number;
|
||||||
@@ -602,7 +637,7 @@ export class ClientsService {
|
|||||||
saldo::text AS saldo
|
saldo::text AS saldo
|
||||||
FROM sar.vw_ctr
|
FROM sar.vw_ctr
|
||||||
WHERE id_cliente = $1
|
WHERE id_cliente = $1
|
||||||
AND id_empresa = 1
|
AND id_empresa = $3
|
||||||
AND situacao = 'A'
|
AND situacao = 'A'
|
||||||
AND saldo > 0
|
AND saldo > 0
|
||||||
ORDER BY dt_vencimento ASC
|
ORDER BY dt_vencimento ASC
|
||||||
@@ -610,6 +645,7 @@ export class ClientsService {
|
|||||||
`,
|
`,
|
||||||
idCliente,
|
idCliente,
|
||||||
limit,
|
limit,
|
||||||
|
idEmpresaMatriz,
|
||||||
);
|
);
|
||||||
|
|
||||||
const toDate = (d: Date | string): string =>
|
const toDate = (d: Date | string): string =>
|
||||||
@@ -626,8 +662,12 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
// NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000)
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
const idEmpresaFiscal = idEmpresaMatriz + 9000;
|
||||||
|
|
||||||
type NfRow = {
|
type NfRow = {
|
||||||
id_nf: number;
|
id_nf: number;
|
||||||
@@ -653,9 +693,9 @@ export class ClientsService {
|
|||||||
JOIN sar.vw_pedidos_erp p
|
JOIN sar.vw_pedidos_erp p
|
||||||
ON p.numero = nf.num_entrega
|
ON p.numero = nf.num_entrega
|
||||||
AND p.tipo = 'E'
|
AND p.tipo = 'E'
|
||||||
AND p.id_empresa IN (1, 9001)
|
AND p.id_empresa IN ($3, $4)
|
||||||
WHERE p.id_cliente = $1
|
WHERE p.id_cliente = $1
|
||||||
AND nf.id_empresa = 1
|
AND nf.id_empresa = $3
|
||||||
AND nf.status = 'E'
|
AND nf.status = 'E'
|
||||||
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||||
ORDER BY nf.id_nf
|
ORDER BY nf.id_nf
|
||||||
@@ -665,6 +705,8 @@ export class ClientsService {
|
|||||||
`,
|
`,
|
||||||
idCliente,
|
idCliente,
|
||||||
limit,
|
limit,
|
||||||
|
idEmpresaMatriz,
|
||||||
|
idEmpresaFiscal,
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map((r: NfRow) => ({
|
return rows.map((r: NfRow) => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
@@ -11,6 +11,15 @@ export class DashboardController {
|
|||||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: dashboards gerenciais agregam dados da empresa toda (faturamento,
|
||||||
|
// ranking de todos os reps) — rep não pode acessá-los.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException('Apenas supervisores e gerentes podem acessar este painel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('rep')
|
@Get('rep')
|
||||||
repDashboard(): Promise<RepDashboard> {
|
repDashboard(): Promise<RepDashboard> {
|
||||||
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
||||||
@@ -18,6 +27,7 @@ export class DashboardController {
|
|||||||
|
|
||||||
@Get('supervisor')
|
@Get('supervisor')
|
||||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
return this.dashboard.supervisorDashboard();
|
return this.dashboard.supervisorDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +36,7 @@ export class DashboardController {
|
|||||||
@Query('mes') mes?: string,
|
@Query('mes') mes?: string,
|
||||||
@Query('ano') ano?: string,
|
@Query('ano') ano?: string,
|
||||||
): Promise<ManagerDashboard> {
|
): Promise<ManagerDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
return this.dashboard.managerDashboard(
|
return this.dashboard.managerDashboard(
|
||||||
mes ? parseInt(mes, 10) : undefined,
|
mes ? parseInt(mes, 10) : undefined,
|
||||||
ano ? parseInt(ano, 10) : undefined,
|
ano ? parseInt(ano, 10) : undefined,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface';
|
import {
|
||||||
|
SubscribePayloadSchema,
|
||||||
|
UnsubscribePayloadSchema,
|
||||||
|
type SubscribePayload,
|
||||||
|
} from '@sar/api-interface';
|
||||||
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
||||||
|
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
|
||||||
|
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
@@ -18,8 +23,8 @@ export class NotificationsController {
|
|||||||
|
|
||||||
@Delete('unsubscribe')
|
@Delete('unsubscribe')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
|
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
|
||||||
await this.svc.unsubscribe(body.endpoint);
|
await this.svc.unsubscribe(req.user.sub, body.endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('pending-count')
|
@Get('pending-count')
|
||||||
|
|||||||
@@ -40,11 +40,15 @@ export class NotificationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async unsubscribe(endpoint: string): Promise<void> {
|
// Escopado ao dono: só remove subscription do próprio usuário — quem souber o
|
||||||
|
// endpoint de terceiro não consegue desregistrá-lo.
|
||||||
|
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : null;
|
||||||
|
|
||||||
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
|
await prisma.pushSubscription.deleteMany({ where: { endpoint, idEmpresa, codVendedor } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ export class OrdersService {
|
|||||||
dataHistorico.setDate(dataHistorico.getDate() - 90);
|
dataHistorico.setDate(dataHistorico.getDate() - 90);
|
||||||
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
|
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Defesa em profundidade: o contrato já exige YYYY-MM-DD, mas como as datas
|
||||||
|
// são interpoladas no SQL abaixo, revalidamos antes de montar a query.
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if ((from && !DATE_RE.test(from)) || (to && !DATE_RE.test(to))) {
|
||||||
|
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
|
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
|
||||||
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
|
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
|
||||||
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
|
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
|
||||||
@@ -301,13 +308,26 @@ export class OrdersService {
|
|||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const role = this.cls.get('role');
|
||||||
const userId = this.cls.get('userId') ?? '0';
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
const codVendedor = parseInt(userId, 10);
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
// PGD-AUTHZ: rep só lança pedido para cliente da própria carteira.
|
||||||
|
if (role === 'rep') {
|
||||||
|
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
dto.idCliente,
|
||||||
|
);
|
||||||
|
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||||
|
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency-Key: retorna pedido existente sem re-processar.
|
||||||
|
// Escopado a empresa + vendedor — chave de terceiro não vaza pedido alheio.
|
||||||
if (dto.idempotencyKey) {
|
if (dto.idempotencyKey) {
|
||||||
const existing = await prisma.pedido.findUnique({
|
const existing = await prisma.pedido.findFirst({
|
||||||
where: { idempotencyKey: dto.idempotencyKey },
|
where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
|
||||||
include: {
|
include: {
|
||||||
itens: { orderBy: { ordem: 'asc' } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
historico: { orderBy: { changedAt: 'asc' } },
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
@@ -399,7 +419,10 @@ export class OrdersService {
|
|||||||
|
|
||||||
// Rep só transmite o próprio orçamento
|
// Rep só transmite o próprio orçamento
|
||||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
const pedido = await prisma.pedido.findFirst({
|
||||||
|
where: { id, idEmpresa, ...repFilter },
|
||||||
|
include: { itens: { orderBy: { ordem: 'asc' } } },
|
||||||
|
});
|
||||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -417,6 +440,8 @@ export class OrdersService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||||
await prisma.historicoPedido.create({
|
await prisma.historicoPedido.create({
|
||||||
@@ -440,6 +465,119 @@ export class OrdersService {
|
|||||||
return this.mapDetail(final);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
||||||
|
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
|
||||||
|
// global — deve caber no limite do grupo do produto (alcada_desconto por
|
||||||
|
// codGrupo; 0 = default), somado a promoção ativa criada pelo gerente para o
|
||||||
|
// produto/grupo. Cobre o desconto lançado no item E desconto disfarçado de
|
||||||
|
// preço unitário reduzido. Preço de tabela: pauta do pedido > promocional >
|
||||||
|
// preço base; produto sem referência valida só pelo campo de desconto.
|
||||||
|
private async assertAlcadaItens(
|
||||||
|
pedido: {
|
||||||
|
descontoPerc: Prisma.Decimal;
|
||||||
|
idPauta: number | null;
|
||||||
|
itens: {
|
||||||
|
ordem: number;
|
||||||
|
idProduto: number;
|
||||||
|
codProduto: string | null;
|
||||||
|
precoUnitario: Prisma.Decimal;
|
||||||
|
descontoPerc: Prisma.Decimal;
|
||||||
|
}[];
|
||||||
|
},
|
||||||
|
limitMap: Map<number, number>,
|
||||||
|
limiteDefault: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
if (pedido.itens.length === 0) return;
|
||||||
|
const prodIds = [...new Set(pedido.itens.map((i) => i.idProduto))];
|
||||||
|
|
||||||
|
interface ProdRow {
|
||||||
|
id_erp: number;
|
||||||
|
codigo: string | null;
|
||||||
|
cod_grupo: number | null;
|
||||||
|
vl_preco1: string | null;
|
||||||
|
preco_promocional: string | null;
|
||||||
|
preco_pauta: string | null;
|
||||||
|
}
|
||||||
|
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
|
||||||
|
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
|
||||||
|
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo,
|
||||||
|
p.vl_preco1::text, p.preco_promocional::text,
|
||||||
|
${
|
||||||
|
pedido.idPauta != null
|
||||||
|
? `(SELECT pp.preco1::text FROM vw_pauta_produtos pp
|
||||||
|
WHERE pp.id_pauta = ${Number(pedido.idPauta)}
|
||||||
|
AND pp.id_produto = p.id_erp LIMIT 1)`
|
||||||
|
: 'NULL'
|
||||||
|
} AS preco_pauta
|
||||||
|
FROM vw_produtos p
|
||||||
|
WHERE p.id_empresa = ${idMatriz}
|
||||||
|
AND p.id_erp IN (${prodIds.join(',')})
|
||||||
|
`);
|
||||||
|
const prodMap = new Map(prodRows.map((p) => [Number(p.id_erp), p]));
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const promos = await prisma.promocao.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const descGlobal = Number(pedido.descontoPerc);
|
||||||
|
const problemas: string[] = [];
|
||||||
|
|
||||||
|
for (const it of pedido.itens) {
|
||||||
|
const prod = prodMap.get(it.idProduto);
|
||||||
|
const codGrupo = prod?.cod_grupo != null ? Number(prod.cod_grupo) : null;
|
||||||
|
const codigo = prod?.codigo?.trim() || it.codProduto || String(it.idProduto);
|
||||||
|
|
||||||
|
const promoPct = promos
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
(p.codProduto && prod?.codigo && p.codProduto.trim() === prod.codigo.trim()) ||
|
||||||
|
(p.grpProd && codGrupo != null && p.grpProd.trim() === String(codGrupo)),
|
||||||
|
)
|
||||||
|
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||||
|
|
||||||
|
const limiteItem =
|
||||||
|
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) + promoPct;
|
||||||
|
|
||||||
|
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
||||||
|
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
||||||
|
const precoBase = precoPauta > 0 ? precoPauta : Number(prod?.vl_preco1 ?? 0);
|
||||||
|
const precoPromo = Number(prod?.preco_promocional ?? 0);
|
||||||
|
const tabela = precoPromo > 0 && precoPromo < precoBase ? precoPromo : precoBase;
|
||||||
|
|
||||||
|
const descItem = Number(it.descontoPerc);
|
||||||
|
const precoLiquido = Number(it.precoUnitario) * (1 - descItem / 100) * (1 - descGlobal / 100);
|
||||||
|
|
||||||
|
// Desconto efetivo: vs tabela quando há referência; senão só os percentuais
|
||||||
|
const descEfetivo =
|
||||||
|
tabela > 0
|
||||||
|
? (1 - precoLiquido / tabela) * 100
|
||||||
|
: (1 - (1 - descItem / 100) * (1 - descGlobal / 100)) * 100;
|
||||||
|
|
||||||
|
if (descEfetivo > limiteItem + 0.01) {
|
||||||
|
problemas.push(
|
||||||
|
`item ${it.ordem} (${codigo}) com desconto efetivo de ${descEfetivo.toFixed(1)}% — máximo permitido ${limiteItem}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problemas.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { Controller, Get, Post, Patch, Param, ParseIntPipe, Body } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { SacService } from './sac.service';
|
import { SacService } from './sac.service';
|
||||||
|
|
||||||
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
||||||
@@ -39,25 +50,42 @@ export class SacRepController {
|
|||||||
|
|
||||||
@Controller('ger/chamados')
|
@Controller('ger/chamados')
|
||||||
export class SacGerController {
|
export class SacGerController {
|
||||||
constructor(private readonly sac: SacService) {}
|
constructor(
|
||||||
|
private readonly sac: SacService,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: visão da empresa toda (detalhe sem checagem de dono) — só gestores.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Apenas supervisores e gerentes podem acessar os chamados da empresa',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
listar() {
|
listar() {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.listarEmpresa();
|
return this.sac.listarEmpresa();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detalhe(@Param('id', ParseIntPipe) id: number) {
|
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.detalhe(id, 'empresa');
|
return this.sac.detalhe(id, 'empresa');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/mensagens')
|
@Post(':id/mensagens')
|
||||||
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/resolver')
|
@Patch(':id/resolver')
|
||||||
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const CACHEABLE_API = [
|
|||||||
|
|
||||||
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
globalThis.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
@@ -102,10 +102,10 @@ function offlineResponse() {
|
|||||||
|
|
||||||
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
globalThis.addEventListener('push', (event) => {
|
||||||
const data = event.data?.json() ?? {};
|
const data = event.data?.json() ?? {};
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(data.title ?? 'SAR', {
|
globalThis.registration.showNotification(data.title ?? 'SAR', {
|
||||||
body: data.body ?? '',
|
body: data.body ?? '',
|
||||||
icon: '/sar-icon.png',
|
icon: '/sar-icon.png',
|
||||||
badge: '/sar-icon.png',
|
badge: '/sar-icon.png',
|
||||||
@@ -114,7 +114,7 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
globalThis.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const url = event.notification.data?.url;
|
const url = event.notification.data?.url;
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
@@ -49,11 +49,15 @@ function TabDescontos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit(row: AlcadaDescontoItem) {
|
async function saveEdit(row: AlcadaDescontoItem) {
|
||||||
|
try {
|
||||||
await upsert.mutateAsync({
|
await upsert.mutateAsync({
|
||||||
codVendedor: row.codVendedor,
|
codVendedor: row.codVendedor,
|
||||||
codGrupo: row.codGrupo,
|
codGrupo: row.codGrupo,
|
||||||
limitePerc: editValue,
|
limitePerc: editValue,
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
setEditingKey(null);
|
setEditingKey(null);
|
||||||
void msg.success('Limite atualizado');
|
void msg.success('Limite atualizado');
|
||||||
}
|
}
|
||||||
@@ -197,6 +201,7 @@ function TabPromocoes() {
|
|||||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
dataFim: fim.format('YYYY-MM-DD'),
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
if (editTarget) {
|
if (editTarget) {
|
||||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||||
void msg.success('Promocao atualizada');
|
void msg.success('Promocao atualizada');
|
||||||
@@ -204,11 +209,18 @@ function TabPromocoes() {
|
|||||||
await createMutation.mutateAsync(body);
|
await createMutation.mutateAsync(body);
|
||||||
void msg.success('Promocao criada');
|
void msg.success('Promocao criada');
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
await deleteMutation.mutateAsync(id);
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
void msg.success('Promocao removida');
|
void msg.success('Promocao removida');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export function CarteirePage() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
title="Erro ao carregar carteira"
|
message="Erro ao carregar carteira"
|
||||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
import { EyeOutlined } from '@ant-design/icons';
|
import { EyeOutlined } from '@ant-design/icons';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import type { ProdutoSummary } from '@sar/api-interface';
|
import type { ProdutoSummary } from '@sar/api-interface';
|
||||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||||
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||||
@@ -32,6 +30,8 @@ import {
|
|||||||
} from '../../lib/queries/clients';
|
} from '../../lib/queries/clients';
|
||||||
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const ACTIVITY_COLOR: Record<string, string> = {
|
const ACTIVITY_COLOR: Record<string, string> = {
|
||||||
|
|||||||
@@ -1087,7 +1087,7 @@ export function ClientsPage() {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const r = [...rows];
|
const r = [...rows];
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ function OportModal({
|
|||||||
observacoes: values.observacoes ?? null,
|
observacoes: values.observacoes ?? null,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -479,7 +481,7 @@ export function FunilPage() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
title="Erro ao carregar funil"
|
message="Erro ao carregar funil"
|
||||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
style={{ marginBottom: 16, flexShrink: 0 }}
|
style={{ marginBottom: 16, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -129,7 +129,12 @@ export function NewClientPage() {
|
|||||||
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await createMutation.mutateAsync(payload);
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.sincronizado && result.erroSync) {
|
if (!result.sincronizado && result.erroSync) {
|
||||||
setSyncError(result.erroSync);
|
setSyncError(result.erroSync);
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import {
|
|||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
@@ -46,6 +44,8 @@ import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
|
|||||||
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@@ -667,7 +667,7 @@ export function OrdersPage() {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
import { Chart } from 'react-chartjs-2';
|
import { Chart } from 'react-chartjs-2';
|
||||||
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
|
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
|
||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||||
|
import { useCurrentUser } from '../../lib/queries/auth';
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -47,8 +49,6 @@ ChartJS.register(
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
Legend,
|
Legend,
|
||||||
);
|
);
|
||||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
|
||||||
import { useCurrentUser } from '../../lib/queries/auth';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: P
|
|||||||
anotacoes: values.anotacoes || undefined,
|
anotacoes: values.anotacoes || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await createMutation.mutateAsync(payload);
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.sincronizado && result.erroSync) {
|
if (!result.sincronizado && result.erroSync) {
|
||||||
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { Alert, Button, Flex, Grid, Tooltip } from 'antd';
|
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
|
||||||
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Topbar } from './Topbar';
|
import { Topbar } from './Topbar';
|
||||||
@@ -7,6 +7,7 @@ import { Sidebar } from './Sidebar';
|
|||||||
import { BottomNav } from './BottomNav';
|
import { BottomNav } from './BottomNav';
|
||||||
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||||
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||||
|
import { registerMessageApi } from '../../lib/feedback';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@@ -18,10 +19,16 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isOnline = useNetworkStatus();
|
const isOnline = useNetworkStatus();
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
|
const { message } = App.useApp();
|
||||||
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
||||||
const isDesktop = !!screens.lg;
|
const isDesktop = !!screens.lg;
|
||||||
useOfflineSync();
|
useOfflineSync();
|
||||||
|
|
||||||
|
// Disponibiliza o message (com tema) para os caches do TanStack Query
|
||||||
|
useEffect(() => {
|
||||||
|
registerMessageApi(message);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
||||||
{!isOnline && (
|
{!isOnline && (
|
||||||
|
|||||||
15
apps/web/src/lib/feedback.ts
Normal file
15
apps/web/src/lib/feedback.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MessageInstance } from 'antd/es/message/interface';
|
||||||
|
|
||||||
|
// Instância do message obtida via App.useApp() (herda tema/contexto), registrada
|
||||||
|
// pelo AppShell no mount. Fora do React (ex.: caches do TanStack Query) usamos
|
||||||
|
// esta referência em vez do message estático do AntD.
|
||||||
|
let messageApi: MessageInstance | null = null;
|
||||||
|
|
||||||
|
export function registerMessageApi(api: MessageInstance): void {
|
||||||
|
messageApi = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key fixa colapsa erros repetidos (várias queries falhando juntas) num toast só.
|
||||||
|
export function notifyError(content: string, key?: string): void {
|
||||||
|
messageApi?.error({ content, key, duration: 5 });
|
||||||
|
}
|
||||||
@@ -19,9 +19,7 @@ export function useCreateOportunidade() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
||||||
apiFetch('/funil', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
|
apiFetch('/funil', { method: 'POST', body: dto }).then((r) => OportunidadeSchema.parse(r)),
|
||||||
OportunidadeSchema.parse(r),
|
|
||||||
),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -30,7 +28,7 @@ export function useUpdateOportunidade() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
||||||
apiFetch(`/funil/${id}`, { method: 'PATCH', body: JSON.stringify(dto) }).then((r) =>
|
apiFetch(`/funil/${id}`, { method: 'PATCH', body: dto }).then((r) =>
|
||||||
OportunidadeSchema.parse(r),
|
OportunidadeSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function useUpsertDesconto() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: UpsertDescontoBody) =>
|
mutationFn: (body: UpsertDescontoBody) =>
|
||||||
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }),
|
apiFetch('/politicas/descontos', { method: 'POST', body }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ export function useCreatePromocao() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: CreatePromocaoBody) =>
|
mutationFn: (body: CreatePromocaoBody) =>
|
||||||
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }),
|
apiFetch('/politicas/promocoes', { method: 'POST', body }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
@@ -88,7 +88,7 @@ export function useUpdatePromocao() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
|
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
|
||||||
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ export function useCreateChamado() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
|
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
|
||||||
apiFetch('/chamados', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
|
apiFetch('/chamados', { method: 'POST', body: dto }).then((r) => ChamadoSchema.parse(r)),
|
||||||
ChamadoSchema.parse(r),
|
|
||||||
),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,8 +38,8 @@ export function useAddMensagemRep(id: number) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||||
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
|
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||||
(r) => ChamadoSchema.parse(r),
|
ChamadoSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['chamados', id] });
|
qc.invalidateQueries({ queryKey: ['chamados', id] });
|
||||||
@@ -79,8 +77,8 @@ export function useAddMensagemEmpresa(id: number) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||||
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
|
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||||
(r) => ChamadoSchema.parse(r),
|
ChamadoSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||||
@@ -93,8 +91,8 @@ export function useResolverChamado(id: number) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
|
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
|
||||||
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: JSON.stringify(dto) }).then(
|
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: dto }).then((r) =>
|
||||||
(r) => ChamadoSchema.parse(r),
|
ChamadoSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||||
|
import { ApiError } from './api-client';
|
||||||
|
import { notifyError } from './feedback';
|
||||||
|
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.problem.detail ?? error.problem.title ?? 'Erro no servidor';
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
return 'Erro inesperado';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueryClient canônico do SAR.
|
* QueryClient canônico do SAR.
|
||||||
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
||||||
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
||||||
|
* Erros nunca são silenciosos: falha de query/mutation sem tratamento local
|
||||||
|
* vira toast — Sandra/Daniel jamais devem "achar que salvou".
|
||||||
*/
|
*/
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
notifyError(`Falha ao carregar dados: ${describeError(error)}`, 'query-error');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error, _variables, _context, mutation) => {
|
||||||
|
if (mutation.options.onError) return; // a tela já dá feedback próprio
|
||||||
|
notifyError(describeError(error), 'mutation-error');
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
notFound,
|
notFound,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
import { Typography } from 'antd';
|
import { Button, Result, Typography } from 'antd';
|
||||||
import { AppShell } from '../components/layout/AppShell';
|
import { AppShell } from '../components/layout/AppShell';
|
||||||
import { RepPainel } from '../cockpits/rep/RepPainel';
|
import { RepPainel } from '../cockpits/rep/RepPainel';
|
||||||
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
||||||
@@ -47,6 +47,23 @@ function HomeRoute() {
|
|||||||
return <RepPainel />;
|
return <RepPainel />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error boundary por rota: uma exceção de render (ex.: ZodError de payload
|
||||||
|
// inesperado) mostra esta tela em vez de derrubar o app inteiro em branco.
|
||||||
|
function RouteErrorPage({ error }: { error: Error }) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Algo deu errado"
|
||||||
|
subTitle={error.message || 'Erro inesperado ao exibir esta tela.'}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
Recarregar
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NotFoundPage() {
|
function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 48, textAlign: 'center' }}>
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||||
@@ -67,6 +84,7 @@ const rootRoute = createRootRoute({
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
),
|
),
|
||||||
notFoundComponent: NotFoundPage,
|
notFoundComponent: NotFoundPage,
|
||||||
|
errorComponent: RouteErrorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
@@ -216,6 +234,7 @@ export const router = createRouter({
|
|||||||
routeTree,
|
routeTree,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultErrorComponent: RouteErrorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default defineConfig(() => ({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
passWithNoTests: true,
|
||||||
reporters: ['default'],
|
reporters: ['default'],
|
||||||
coverage: {
|
coverage: {
|
||||||
reportsDirectory: '../../coverage/apps/web',
|
reportsDirectory: '../../coverage/apps/web',
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ export const CreateContatoSchema = z.object({
|
|||||||
empresa: z.string().max(100).optional(),
|
empresa: z.string().max(100).optional(),
|
||||||
cargo: z.string().max(100).optional(),
|
cargo: z.string().max(100).optional(),
|
||||||
departamento: z.string().max(100).optional(),
|
departamento: z.string().max(100).optional(),
|
||||||
dtAniversario: z.string().optional(),
|
dtAniversario: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD')
|
||||||
|
.optional(),
|
||||||
telefone: z.string().max(20).optional(),
|
telefone: z.string().max(20).optional(),
|
||||||
ramal: z.string().max(10).optional(),
|
ramal: z.string().max(10).optional(),
|
||||||
celular: z.string().max(20).optional(),
|
celular: z.string().max(20).optional(),
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
||||||
|
|
||||||
|
export const UnsubscribePayloadSchema = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
});
|
||||||
|
export type UnsubscribePayload = z.infer<typeof UnsubscribePayloadSchema>;
|
||||||
|
|
||||||
export const PendingCountResponseSchema = z.object({
|
export const PendingCountResponseSchema = z.object({
|
||||||
count: z.number().int().min(0),
|
count: z.number().int().min(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,12 +101,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
|
|||||||
|
|
||||||
// ─── List query + response ────────────────────────────────────────────────────
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Datas de filtro sempre YYYY-MM-DD — interpoladas em SQL no servidor, o formato
|
||||||
|
// fechado é parte da defesa contra injection, não só validação de UX.
|
||||||
|
export const DateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD');
|
||||||
|
|
||||||
export const PedidoListQuerySchema = z.object({
|
export const PedidoListQuerySchema = z.object({
|
||||||
idCliente: z.coerce.number().int().optional(),
|
idCliente: z.coerce.number().int().optional(),
|
||||||
situa: z.coerce.number().int().optional(),
|
situa: z.coerce.number().int().optional(),
|
||||||
numPedSar: z.string().optional(),
|
numPedSar: z.string().optional(),
|
||||||
from: z.string().optional(),
|
from: DateOnlySchema.optional(),
|
||||||
to: z.string().optional(),
|
to: DateOnlySchema.optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
@@ -184,8 +188,8 @@ export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
|
|||||||
|
|
||||||
export const PedidoErpConsultaQuerySchema = z.object({
|
export const PedidoErpConsultaQuerySchema = z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
from: z.string().optional(),
|
from: DateOnlySchema.optional(),
|
||||||
to: z.string().optional(),
|
to: DateOnlySchema.optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('PingResponseSchema', () => {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'sar-api',
|
service: 'sar-api',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
workspaceId: 'dev-workspace',
|
idEmpresa: 1,
|
||||||
requestId: '550e8400-e29b-41d4-a716-446655440000',
|
requestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
uptimeSeconds: 42,
|
uptimeSeconds: 42,
|
||||||
now: '2026-05-27T12:34:56.000Z',
|
now: '2026-05-27T12:34:56.000Z',
|
||||||
@@ -36,8 +36,8 @@ describe('PingResponseSchema', () => {
|
|||||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejeita workspaceId vazio', () => {
|
it('rejeita idEmpresa não inteiro', () => {
|
||||||
const bad = { ...validPayload, workspaceId: '' };
|
const bad = { ...validPayload, idEmpresa: 1.5 };
|
||||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user