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, "''");
|
||||
}
|
||||
|
||||
// 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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -1087,7 +1087,7 @@ export function ClientsPage() {
|
||||
limit,
|
||||
});
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const r = [...rows];
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
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();
|
||||
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'] }),
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user