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:
2026-07-21 20:40:19 +00:00
parent 2a33a5aa4b
commit 518769218f
29 changed files with 400 additions and 75 deletions

View File

@@ -36,6 +36,12 @@ function escSql(s: string): string {
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
interface ClientRow {
id_cliente: number;
@@ -108,6 +114,25 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
export class ClientsService {
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> {
const prisma = this.cls.get('prisma');
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[]> {
await this.assertClienteDaCarteira(idCorrent);
const prisma = this.cls.get('prisma');
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> {
await this.assertClienteDaCarteira(idCorrent);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
@@ -337,7 +364,7 @@ export class ClientsService {
${idEmpresa}, ${codVendedor}, ${idCorrent},
'${esc(dto.nome)}',
${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.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
)
@@ -370,6 +397,7 @@ export class ClientsService {
}
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
@@ -430,12 +458,13 @@ export class ClientsService {
}
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
interface Row {
id_produto: number;
@@ -493,8 +522,10 @@ export class ClientsService {
}
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
interface Row {
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
FROM sar.vw_ctr
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresaMatriz}
AND situacao = 'A'
AND saldo > 0
`);
@@ -528,6 +560,7 @@ export class ClientsService {
}
async findOne(idCliente: number): Promise<ClientDetail> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
@@ -579,8 +612,10 @@ export class ClientsService {
}
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
type CtrRow = {
id_ctr: number;
@@ -602,7 +637,7 @@ export class ClientsService {
saldo::text AS saldo
FROM sar.vw_ctr
WHERE id_cliente = $1
AND id_empresa = 1
AND id_empresa = $3
AND situacao = 'A'
AND saldo > 0
ORDER BY dt_vencimento ASC
@@ -610,6 +645,7 @@ export class ClientsService {
`,
idCliente,
limit,
idEmpresaMatriz,
);
const toDate = (d: Date | string): string =>
@@ -626,8 +662,12 @@ export class ClientsService {
}
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma');
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 = {
id_nf: number;
@@ -653,9 +693,9 @@ export class ClientsService {
JOIN sar.vw_pedidos_erp p
ON p.numero = nf.num_entrega
AND p.tipo = 'E'
AND p.id_empresa IN (1, 9001)
AND p.id_empresa IN ($3, $4)
WHERE p.id_cliente = $1
AND nf.id_empresa = 1
AND nf.id_empresa = $3
AND nf.status = 'E'
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
ORDER BY nf.id_nf
@@ -665,6 +705,8 @@ export class ClientsService {
`,
idCliente,
limit,
idEmpresaMatriz,
idEmpresaFiscal,
);
return rows.map((r: NfRow) => ({

View File

@@ -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 type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -11,6 +11,15 @@ export class DashboardController {
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')
repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
@@ -18,6 +27,7 @@ export class DashboardController {
@Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> {
this.assertGestor();
return this.dashboard.supervisorDashboard();
}
@@ -26,6 +36,7 @@ export class DashboardController {
@Query('mes') mes?: string,
@Query('ano') ano?: string,
): Promise<ManagerDashboard> {
this.assertGestor();
return this.dashboard.managerDashboard(
mes ? parseInt(mes, 10) : undefined,
ano ? parseInt(ano, 10) : undefined,

View File

@@ -1,10 +1,15 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
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 { NotificationsService } from './notifications.service';
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
@Controller('notifications')
export class NotificationsController {
@@ -18,8 +23,8 @@ export class NotificationsController {
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
await this.svc.unsubscribe(body.endpoint);
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
await this.svc.unsubscribe(req.user.sub, body.endpoint);
}
@Get('pending-count')

View File

@@ -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');
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> {

View File

@@ -127,6 +127,13 @@ export class OrdersService {
dataHistorico.setDate(dataHistorico.getDate() - 90);
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 fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
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');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0';
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) {
const existing = await prisma.pedido.findUnique({
where: { idempotencyKey: dto.idempotencyKey },
const existing = await prisma.pedido.findFirst({
where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
@@ -399,7 +419,10 @@ export class OrdersService {
// Rep só transmite o próprio orçamento
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.situa !== SITUA_ORCAMENTO)
throw new BadRequestException(
@@ -417,6 +440,8 @@ export class OrdersService {
);
}
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
await prisma.historicoPedido.create({
@@ -440,6 +465,119 @@ export class OrdersService {
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> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');

View File

@@ -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 { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { SacService } from './sac.service';
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
@@ -39,25 +50,42 @@ export class SacRepController {
@Controller('ger/chamados')
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()
listar() {
this.assertGestor();
return this.sac.listarEmpresa();
}
@Get(':id')
detalhe(@Param('id', ParseIntPipe) id: number) {
this.assertGestor();
return this.sac.detalhe(id, 'empresa');
}
@Post(':id/mensagens')
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
this.assertGestor();
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
}
@Patch(':id/resolver')
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
this.assertGestor();
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
}
}

View File

@@ -18,7 +18,7 @@ const CACHEABLE_API = [
// ── Fetch ──────────────────────────────────────────────────────────────────────
self.addEventListener('fetch', (event) => {
globalThis.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
@@ -102,10 +102,10 @@ function offlineResponse() {
// ── Push (C6) ─────────────────────────────────────────────────────────────────
self.addEventListener('push', (event) => {
globalThis.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'SAR', {
globalThis.registration.showNotification(data.title ?? 'SAR', {
body: data.body ?? '',
icon: '/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();
const url = event.notification.data?.url;
if (url) {

View File

@@ -49,11 +49,15 @@ function TabDescontos() {
}
async function saveEdit(row: AlcadaDescontoItem) {
await upsert.mutateAsync({
codVendedor: row.codVendedor,
codGrupo: row.codGrupo,
limitePerc: editValue,
});
try {
await upsert.mutateAsync({
codVendedor: row.codVendedor,
codGrupo: row.codGrupo,
limitePerc: editValue,
});
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
setEditingKey(null);
void msg.success('Limite atualizado');
}
@@ -197,18 +201,26 @@ function TabPromocoes() {
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
void msg.success('Promocao atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Promocao criada');
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
void msg.success('Promocao atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Promocao criada');
}
} catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
}
setModalOpen(false);
}
async function handleDelete(id: number) {
await deleteMutation.mutateAsync(id);
try {
await deleteMutation.mutateAsync(id);
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
void msg.success('Promocao removida');
}

View File

@@ -315,7 +315,7 @@ export function CarteirePage() {
<Alert
type="error"
showIcon
title="Erro ao carregar carteira"
message="Erro ao carregar carteira"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
style={{ marginBottom: 24 }}
/>

View File

@@ -1,13 +1,13 @@
import { useState } from 'react';
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
const { useBreakpoint } = Grid;
import { EyeOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
import { ProductDetailDrawer } from './ProductDetailDrawer';
const { useBreakpoint } = Grid;
const { Title, Text } = Typography;
const { Search } = Input;

View File

@@ -15,8 +15,6 @@ import {
Badge,
} from 'antd';
import { useState } from 'react';
const { useBreakpoint } = Grid;
import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
@@ -32,6 +30,8 @@ import {
} from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts';
const { useBreakpoint } = Grid;
const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = {

View File

@@ -1087,7 +1087,7 @@ export function ClientsPage() {
limit,
});
const rows = data?.data ?? [];
const rows = useMemo(() => data?.data ?? [], [data]);
const sorted = useMemo(() => {
const r = [...rows];

View File

@@ -231,6 +231,8 @@ function OportModal({
observacoes: values.observacoes ?? null,
});
onClose();
} catch {
// erro já notificado pelo handler global do QueryClient; modal fica aberto
} finally {
setSaving(false);
}
@@ -479,7 +481,7 @@ export function FunilPage() {
<Alert
type="error"
showIcon
title="Erro ao carregar funil"
message="Erro ao carregar funil"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
style={{ marginBottom: 16, flexShrink: 0 }}
/>

View File

@@ -129,7 +129,12 @@ export function NewClientPage() {
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) {
setSyncError(result.erroSync);

View File

@@ -22,8 +22,6 @@ import {
import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd';
import type { Dayjs } from 'dayjs';
const { RangePicker } = DatePicker;
import {
CheckCircleOutlined,
ClockCircleOutlined,
@@ -46,6 +44,8 @@ import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
import { apiFetch } from '../../lib/api-client';
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -667,7 +667,7 @@ export function OrdersPage() {
limit,
});
const rows = data?.data ?? [];
const rows = useMemo(() => data?.data ?? [], [data]);
const total = data?.total ?? 0;
const hasFilters = !!query || !!situaFilter || !!period || !!range;

View File

@@ -37,6 +37,8 @@ import {
import { Chart } from 'react-chartjs-2';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } 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(
CategoryScale,
@@ -47,8 +49,6 @@ ChartJS.register(
ChartTooltip,
Legend,
);
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography;

View File

@@ -94,7 +94,12 @@ export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: P
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) {
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);

View File

@@ -1,5 +1,5 @@
import { type ReactNode } from 'react';
import { Alert, Button, Flex, Grid, Tooltip } from 'antd';
import { useEffect, type ReactNode } from 'react';
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar';
@@ -7,6 +7,7 @@ import { Sidebar } from './Sidebar';
import { BottomNav } from './BottomNav';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
import { registerMessageApi } from '../../lib/feedback';
const { useBreakpoint } = Grid;
@@ -18,10 +19,16 @@ export function AppShell({ children }: AppShellProps) {
const navigate = useNavigate();
const isOnline = useNetworkStatus();
const screens = useBreakpoint();
const { message } = App.useApp();
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
const isDesktop = !!screens.lg;
useOfflineSync();
// Disponibiliza o message (com tema) para os caches do TanStack Query
useEffect(() => {
registerMessageApi(message);
}, [message]);
return (
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
{!isOnline && (

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

View File

@@ -19,9 +19,7 @@ export function useCreateOportunidade() {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
apiFetch('/funil', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
OportunidadeSchema.parse(r),
),
apiFetch('/funil', { method: 'POST', body: dto }).then((r) => OportunidadeSchema.parse(r)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
});
}
@@ -30,7 +28,7 @@ export function useUpdateOportunidade() {
const qc = useQueryClient();
return useMutation({
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),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),

View File

@@ -67,7 +67,7 @@ export function useUpsertDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }),
apiFetch('/politicas/descontos', { method: 'POST', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
});
}
@@ -76,7 +76,7 @@ export function useCreatePromocao() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreatePromocaoBody) =>
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }),
apiFetch('/politicas/promocoes', { method: 'POST', body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
@@ -88,7 +88,7 @@ export function useUpdatePromocao() {
const qc = useQueryClient();
return useMutation({
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: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });

View File

@@ -29,9 +29,7 @@ export function useCreateChamado() {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
apiFetch('/chamados', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
ChamadoSchema.parse(r),
),
apiFetch('/chamados', { method: 'POST', body: dto }).then((r) => ChamadoSchema.parse(r)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
});
}
@@ -40,8 +38,8 @@ export function useAddMensagemRep(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
(r) => ChamadoSchema.parse(r),
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['chamados', id] });
@@ -79,8 +77,8 @@ export function useAddMensagemEmpresa(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
(r) => ChamadoSchema.parse(r),
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
@@ -93,8 +91,8 @@ export function useResolverChamado(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: JSON.stringify(dto) }).then(
(r) => ChamadoSchema.parse(r),
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: dto }).then((r) =>
ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });

View File

@@ -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.
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
* 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({
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: {
queries: {
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos

View File

@@ -5,7 +5,7 @@ import {
Outlet,
notFound,
} from '@tanstack/react-router';
import { Typography } from 'antd';
import { Button, Result, Typography } from 'antd';
import { AppShell } from '../components/layout/AppShell';
import { RepPainel } from '../cockpits/rep/RepPainel';
import { ClientsPage } from '../cockpits/rep/ClientsPage';
@@ -47,6 +47,23 @@ function HomeRoute() {
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() {
return (
<div style={{ padding: 48, textAlign: 'center' }}>
@@ -67,6 +84,7 @@ const rootRoute = createRootRoute({
</AppShell>
),
notFoundComponent: NotFoundPage,
errorComponent: RouteErrorPage,
});
const indexRoute = createRoute({
@@ -216,6 +234,7 @@ export const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultErrorComponent: RouteErrorPage,
});
declare module '@tanstack/react-router' {

View File

@@ -44,6 +44,7 @@ export default defineConfig(() => ({
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
passWithNoTests: true,
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/web',