From 518769218f15c25849e8cdd78c1cccb3fcd6ddda Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 21 Jul 2026 20:40:19 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(api,web):=20corrige=20autoriza=C3=A7?= =?UTF-8?q?=C3=A3o=20da=20=C3=A1rea=20do=20rep=20e=20falhas=20silenciosas?= =?UTF-8?q?=20no=20front?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/app/clients/clients.service.ts | 52 ++++++- .../src/app/dashboard/dashboard.controller.ts | 13 +- .../notifications/notifications.controller.ts | 11 +- .../notifications/notifications.service.ts | 8 +- apps/api/src/app/orders/orders.service.ts | 146 +++++++++++++++++- apps/api/src/app/sac/sac.controller.ts | 32 +++- apps/web/public/sw.js | 8 +- apps/web/src/cockpits/ger/PoliticasPage.tsx | 36 +++-- apps/web/src/cockpits/rep/CarteirePage.tsx | 2 +- apps/web/src/cockpits/rep/CatalogPage.tsx | 4 +- .../web/src/cockpits/rep/ClientDetailPage.tsx | 4 +- apps/web/src/cockpits/rep/ClientsPage.tsx | 2 +- apps/web/src/cockpits/rep/FunilPage.tsx | 4 +- apps/web/src/cockpits/rep/NewClientPage.tsx | 7 +- apps/web/src/cockpits/rep/OrdersPage.tsx | 6 +- apps/web/src/cockpits/rep/RepPainel.tsx | 4 +- .../components/contacts/ClientContacts.tsx | 7 +- apps/web/src/components/layout/AppShell.tsx | 11 +- apps/web/src/lib/feedback.ts | 15 ++ apps/web/src/lib/queries/funil.ts | 6 +- apps/web/src/lib/queries/gerente.ts | 6 +- apps/web/src/lib/queries/sac.ts | 16 +- apps/web/src/lib/query-client.ts | 25 ++- apps/web/src/lib/router.tsx | 21 ++- apps/web/vite.config.mts | 1 + .../api-interface/src/lib/client.contract.ts | 5 +- .../src/lib/notifications.contract.ts | 5 + .../api-interface/src/lib/order.contract.ts | 12 +- .../src/lib/ping.contract.spec.ts | 6 +- 29 files changed, 400 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/lib/feedback.ts diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index af03018..0f5af02 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -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) {} + // 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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) => ({ diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts index 2be0234..060e0f0 100644 --- a/apps/api/src/app/dashboard/dashboard.controller.ts +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -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, ) {} + // 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 { return this.dashboard.repDashboard(this.cls.get('userId') ?? ''); @@ -18,6 +27,7 @@ export class DashboardController { @Get('supervisor') supervisorDashboard(): Promise { + this.assertGestor(); return this.dashboard.supervisorDashboard(); } @@ -26,6 +36,7 @@ export class DashboardController { @Query('mes') mes?: string, @Query('ano') ano?: string, ): Promise { + this.assertGestor(); return this.dashboard.managerDashboard( mes ? parseInt(mes, 10) : undefined, ano ? parseInt(ano, 10) : undefined, diff --git a/apps/api/src/app/notifications/notifications.controller.ts b/apps/api/src/app/notifications/notifications.controller.ts index fd421cf..672077c 100644 --- a/apps/api/src/app/notifications/notifications.controller.ts +++ b/apps/api/src/app/notifications/notifications.controller.ts @@ -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 { - await this.svc.unsubscribe(body.endpoint); + async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise { + await this.svc.unsubscribe(req.user.sub, body.endpoint); } @Get('pending-count') diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts index b0d5e18..14a1b7d 100644 --- a/apps/api/src/app/notifications/notifications.service.ts +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -40,11 +40,15 @@ export class NotificationsService { }); } - async unsubscribe(endpoint: string): Promise { + // 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 { 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 { diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index bde8585..bf29e17 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -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, + limiteDefault: number, + ): Promise { + 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(` + 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 { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); diff --git a/apps/api/src/app/sac/sac.controller.ts b/apps/api/src/app/sac/sac.controller.ts index 5333f11..c14ce99 100644 --- a/apps/api/src/app/sac/sac.controller.ts +++ b/apps/api/src/app/sac/sac.controller.ts @@ -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, + ) {} + + // 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)); } } diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index c21e791..5f00edb 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -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) { diff --git a/apps/web/src/cockpits/ger/PoliticasPage.tsx b/apps/web/src/cockpits/ger/PoliticasPage.tsx index ab35375..5e7cfbe 100644 --- a/apps/web/src/cockpits/ger/PoliticasPage.tsx +++ b/apps/web/src/cockpits/ger/PoliticasPage.tsx @@ -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'); } diff --git a/apps/web/src/cockpits/rep/CarteirePage.tsx b/apps/web/src/cockpits/rep/CarteirePage.tsx index 23c97f4..9052803 100644 --- a/apps/web/src/cockpits/rep/CarteirePage.tsx +++ b/apps/web/src/cockpits/rep/CarteirePage.tsx @@ -315,7 +315,7 @@ export function CarteirePage() { diff --git a/apps/web/src/cockpits/rep/CatalogPage.tsx b/apps/web/src/cockpits/rep/CatalogPage.tsx index b1e1d9d..ae727a3 100644 --- a/apps/web/src/cockpits/rep/CatalogPage.tsx +++ b/apps/web/src/cockpits/rep/CatalogPage.tsx @@ -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; diff --git a/apps/web/src/cockpits/rep/ClientDetailPage.tsx b/apps/web/src/cockpits/rep/ClientDetailPage.tsx index 60e1de0..ba7869f 100644 --- a/apps/web/src/cockpits/rep/ClientDetailPage.tsx +++ b/apps/web/src/cockpits/rep/ClientDetailPage.tsx @@ -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 = { diff --git a/apps/web/src/cockpits/rep/ClientsPage.tsx b/apps/web/src/cockpits/rep/ClientsPage.tsx index b9061c5..96cf4b5 100644 --- a/apps/web/src/cockpits/rep/ClientsPage.tsx +++ b/apps/web/src/cockpits/rep/ClientsPage.tsx @@ -1087,7 +1087,7 @@ export function ClientsPage() { limit, }); - const rows = data?.data ?? []; + const rows = useMemo(() => data?.data ?? [], [data]); const sorted = useMemo(() => { const r = [...rows]; diff --git a/apps/web/src/cockpits/rep/FunilPage.tsx b/apps/web/src/cockpits/rep/FunilPage.tsx index 2f9017d..65ff42d 100644 --- a/apps/web/src/cockpits/rep/FunilPage.tsx +++ b/apps/web/src/cockpits/rep/FunilPage.tsx @@ -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() { diff --git a/apps/web/src/cockpits/rep/NewClientPage.tsx b/apps/web/src/cockpits/rep/NewClientPage.tsx index b870dc8..bad91f6 100644 --- a/apps/web/src/cockpits/rep/NewClientPage.tsx +++ b/apps/web/src/cockpits/rep/NewClientPage.tsx @@ -129,7 +129,12 @@ export function NewClientPage() { codPauta: values.codPauta ? Number(values.codPauta) : undefined, }; - const result = await createMutation.mutateAsync(payload); + let result: Awaited>; + try { + result = await createMutation.mutateAsync(payload); + } catch { + return; // erro já notificado pelo handler global do QueryClient + } if (!result.sincronizado && result.erroSync) { setSyncError(result.erroSync); diff --git a/apps/web/src/cockpits/rep/OrdersPage.tsx b/apps/web/src/cockpits/rep/OrdersPage.tsx index 1bf9738..5212242 100644 --- a/apps/web/src/cockpits/rep/OrdersPage.tsx +++ b/apps/web/src/cockpits/rep/OrdersPage.tsx @@ -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; diff --git a/apps/web/src/cockpits/rep/RepPainel.tsx b/apps/web/src/cockpits/rep/RepPainel.tsx index f30b6b0..26b7003 100644 --- a/apps/web/src/cockpits/rep/RepPainel.tsx +++ b/apps/web/src/cockpits/rep/RepPainel.tsx @@ -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; diff --git a/apps/web/src/components/contacts/ClientContacts.tsx b/apps/web/src/components/contacts/ClientContacts.tsx index 09495ce..ef7f969 100644 --- a/apps/web/src/components/contacts/ClientContacts.tsx +++ b/apps/web/src/components/contacts/ClientContacts.tsx @@ -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>; + 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}`); diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index cdb1c73..c7b0e67 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -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 ( {!isOnline && ( diff --git a/apps/web/src/lib/feedback.ts b/apps/web/src/lib/feedback.ts new file mode 100644 index 0000000..458de43 --- /dev/null +++ b/apps/web/src/lib/feedback.ts @@ -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 }); +} diff --git a/apps/web/src/lib/queries/funil.ts b/apps/web/src/lib/queries/funil.ts index c834688..71525f9 100644 --- a/apps/web/src/lib/queries/funil.ts +++ b/apps/web/src/lib/queries/funil.ts @@ -19,9 +19,7 @@ export function useCreateOportunidade() { const qc = useQueryClient(); return useMutation({ mutationFn: (dto: CreateOportunidadeDto): Promise => - 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 => - 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'] }), diff --git a/apps/web/src/lib/queries/gerente.ts b/apps/web/src/lib/queries/gerente.ts index d1c9544..959aba9 100644 --- a/apps/web/src/lib/queries/gerente.ts +++ b/apps/web/src/lib/queries/gerente.ts @@ -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'] }); diff --git a/apps/web/src/lib/queries/sac.ts b/apps/web/src/lib/queries/sac.ts index 279d3e7..4fff33f 100644 --- a/apps/web/src/lib/queries/sac.ts +++ b/apps/web/src/lib/queries/sac.ts @@ -29,9 +29,7 @@ export function useCreateChamado() { const qc = useQueryClient(); return useMutation({ mutationFn: (dto: CreateChamadoDto): Promise => - 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 => - 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 => - 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 => - 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] }); diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts index 0ae6acd..f87ce57 100644 --- a/apps/web/src/lib/query-client.ts +++ b/apps/web/src/lib/query-client.ts @@ -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 diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 9de3073..7889bc1 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -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 ; } +// 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 ( + window.location.reload()}> + Recarregar + + } + /> + ); +} + function NotFoundPage() { return (
@@ -67,6 +84,7 @@ const rootRoute = createRootRoute({ ), 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' { diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index ff66c93..ba7114d 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -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', diff --git a/libs/shared/api-interface/src/lib/client.contract.ts b/libs/shared/api-interface/src/lib/client.contract.ts index dfe2d00..6d767d5 100644 --- a/libs/shared/api-interface/src/lib/client.contract.ts +++ b/libs/shared/api-interface/src/lib/client.contract.ts @@ -93,7 +93,10 @@ export const CreateContatoSchema = z.object({ empresa: z.string().max(100).optional(), cargo: 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(), ramal: z.string().max(10).optional(), celular: z.string().max(20).optional(), diff --git a/libs/shared/api-interface/src/lib/notifications.contract.ts b/libs/shared/api-interface/src/lib/notifications.contract.ts index 9f34a63..e4dae7f 100644 --- a/libs/shared/api-interface/src/lib/notifications.contract.ts +++ b/libs/shared/api-interface/src/lib/notifications.contract.ts @@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({ }); export type SubscribePayload = z.infer; +export const UnsubscribePayloadSchema = z.object({ + endpoint: z.string().url(), +}); +export type UnsubscribePayload = z.infer; + export const PendingCountResponseSchema = z.object({ count: z.number().int().min(0), }); diff --git a/libs/shared/api-interface/src/lib/order.contract.ts b/libs/shared/api-interface/src/lib/order.contract.ts index 96f86bb..37c52eb 100644 --- a/libs/shared/api-interface/src/lib/order.contract.ts +++ b/libs/shared/api-interface/src/lib/order.contract.ts @@ -101,12 +101,16 @@ export type PedidoDetail = z.infer; // ─── 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({ idCliente: z.coerce.number().int().optional(), situa: z.coerce.number().int().optional(), numPedSar: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), + from: DateOnlySchema.optional(), + to: DateOnlySchema.optional(), page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(200).default(50), }); @@ -184,8 +188,8 @@ export type PedidoErpConsultaItem = z.infer; export const PedidoErpConsultaQuerySchema = z.object({ search: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), + from: DateOnlySchema.optional(), + to: DateOnlySchema.optional(), page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(200).default(50), }); diff --git a/libs/shared/api-interface/src/lib/ping.contract.spec.ts b/libs/shared/api-interface/src/lib/ping.contract.spec.ts index 9d10c60..33cb4f0 100644 --- a/libs/shared/api-interface/src/lib/ping.contract.spec.ts +++ b/libs/shared/api-interface/src/lib/ping.contract.spec.ts @@ -5,7 +5,7 @@ describe('PingResponseSchema', () => { status: 'ok', service: 'sar-api', version: '0.1.0', - workspaceId: 'dev-workspace', + idEmpresa: 1, requestId: '550e8400-e29b-41d4-a716-446655440000', uptimeSeconds: 42, now: '2026-05-27T12:34:56.000Z', @@ -36,8 +36,8 @@ describe('PingResponseSchema', () => { expect(() => PingResponseSchema.parse(bad)).toThrow(); }); - it('rejeita workspaceId vazio', () => { - const bad = { ...validPayload, workspaceId: '' }; + it('rejeita idEmpresa não inteiro', () => { + const bad = { ...validPayload, idEmpresa: 1.5 }; expect(() => PingResponseSchema.parse(bad)).toThrow(); }); }); From f543b8ac7fd2d0399326592d74b3c031c08f1f5e Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 22 Jul 2026 17:24:21 +0000 Subject: [PATCH 2/3] feat(politicas): regras de desconto por grupo/subgrupo/rep e condicoes comerciais no catalogo - Nova tabela sar.regras_desconto (grupo, subgrupo, representante, % e vigencia; campos nulos = todos) com migration idempotente, espelho no sar-erp-schema.sql e GRANT no provision-client - CRUD manager-only em /politicas/regras + aba "Regras de Desconto" na tela de Politicas Comerciais (selects com nomes de rep/grupo/subgrupo via /politicas/grupos-produto) - GET /politicas/regras/vigentes (rep ve so as que o alcancam) e GET /politicas/promocoes/vigentes para o catalogo - Regra vigente pre-aplica o desconto no carrinho do novo pedido - Selos "Promocao"/"Cond. especial" na busca de produto, catalogo do pedido e pagina de catalogo; secao "Condicoes Comerciais" no modal de detalhe do produto (descricao, % e validade) - Empty imageStyle deprecado -> styles.image no carrinho Co-Authored-By: Claude Fable 5 --- .../migration.sql | 23 ++ apps/api/prisma/schema.prisma | 24 ++ .../src/app/politicas/politicas.controller.ts | 49 +++ .../src/app/politicas/politicas.service.ts | 221 +++++++++++++ apps/web/src/cockpits/ger/PoliticasPage.tsx | 308 +++++++++++++++++- apps/web/src/cockpits/rep/CatalogPage.tsx | 23 +- apps/web/src/cockpits/rep/NewOrderPage.tsx | 59 +++- .../src/cockpits/rep/ProductDetailDrawer.tsx | 69 +++- apps/web/src/components/CondicaoTag.tsx | 27 ++ apps/web/src/lib/condicoes-comerciais.ts | 64 ++++ apps/web/src/lib/queries/gerente.ts | 63 ++++ apps/web/src/lib/queries/politicas.ts | 33 ++ .../src/lib/politicas.contract.ts | 89 +++++ scripts/provision-client.ts | 1 + scripts/sar-erp-schema.sql | 24 +- 15 files changed, 1065 insertions(+), 12 deletions(-) create mode 100644 apps/api/prisma/migrations/20260722000000_add_regras_desconto/migration.sql create mode 100644 apps/web/src/components/CondicaoTag.tsx create mode 100644 apps/web/src/lib/condicoes-comerciais.ts create mode 100644 apps/web/src/lib/queries/politicas.ts diff --git a/apps/api/prisma/migrations/20260722000000_add_regras_desconto/migration.sql b/apps/api/prisma/migrations/20260722000000_add_regras_desconto/migration.sql new file mode 100644 index 0000000..5712813 --- /dev/null +++ b/apps/api/prisma/migrations/20260722000000_add_regras_desconto/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais) +-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou +-- representante, com vigencia. Campos nulos = "todos". +-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL +-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente. +-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui. +CREATE TABLE IF NOT EXISTS sar.regras_desconto ( + id SERIAL PRIMARY KEY, + id_empresa INTEGER NOT NULL, + descricao VARCHAR(200) NOT NULL, + cod_grupo INTEGER, + cod_subgrupo INTEGER, + cod_vendedor INTEGER, + desc_pct NUMERIC(5,2) NOT NULL, + data_inicio DATE NOT NULL, + data_fim DATE NOT NULL, + ativa BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa); +CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a1899e0..8c54bfd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -171,6 +171,30 @@ model Promocao { @@map("promocoes") } +// ─── RegraDesconto (Políticas Comerciais) ──────────────────────────────────── +// +// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou +// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos". + +model RegraDesconto { + id Int @id @default(autoincrement()) + idEmpresa Int @map("id_empresa") + descricao String + codGrupo Int? @map("cod_grupo") + codSubgrupo Int? @map("cod_subgrupo") + codVendedor Int? @map("cod_vendedor") + descPct Decimal @db.Decimal(5, 2) @map("desc_pct") + dataInicio DateTime @db.Date @map("data_inicio") + dataFim DateTime @db.Date @map("data_fim") + ativa Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + createdBy String @map("created_by") + + @@index([idEmpresa]) + @@index([idEmpresa, dataFim]) + @@map("regras_desconto") +} + // ─── Oportunidade (Funil de Vendas) ────────────────────────────────────────── // // Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente) diff --git a/apps/api/src/app/politicas/politicas.controller.ts b/apps/api/src/app/politicas/politicas.controller.ts index 8af70fd..3afd980 100644 --- a/apps/api/src/app/politicas/politicas.controller.ts +++ b/apps/api/src/app/politicas/politicas.controller.ts @@ -14,17 +14,27 @@ import { UpsertDescontoBodySchema, CreatePromocaoBodySchema, UpdatePromocaoBodySchema, + CreateRegraDescontoBodySchema, + UpdateRegraDescontoBodySchema, type UpsertDescontoBody, type CreatePromocaoBody, type UpdatePromocaoBody, + type CreateRegraDescontoBody, + type UpdateRegraDescontoBody, type AlcadaDescontosResponse, type PromocoesResponse, + type PromocoesVigentesResponse, + type RegrasDescontoResponse, + type RegrasVigentesResponse, + type GruposProdutoResponse, } from '@sar/api-interface'; import { PoliticasService } from './politicas.service'; class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} +class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {} +class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {} @Controller({ path: 'politicas' }) export class PoliticasController { @@ -46,6 +56,11 @@ export class PoliticasController { return this.politicas.listPromocoes(); } + @Get('promocoes/vigentes') + listPromocoesVigentes(): Promise { + return this.politicas.listPromocoesVigentes(); + } + @Post('promocoes') createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> { return this.politicas.createPromocao(body as unknown as CreatePromocaoBody); @@ -64,4 +79,38 @@ export class PoliticasController { deletePromocao(@Param('id', ParseIntPipe) id: number): Promise { return this.politicas.deletePromocao(id); } + + @Get('regras') + listRegras(): Promise { + return this.politicas.listRegras(); + } + + @Get('regras/vigentes') + listRegrasVigentes(): Promise { + return this.politicas.listRegrasVigentes(); + } + + @Post('regras') + createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> { + return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody); + } + + @Patch('regras/:id') + updateRegra( + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdateRegraDescontoDto, + ): Promise { + return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody); + } + + @Delete('regras/:id') + @HttpCode(204) + deleteRegra(@Param('id', ParseIntPipe) id: number): Promise { + return this.politicas.deleteRegra(id); + } + + @Get('grupos-produto') + listGruposProduto(): Promise { + return this.politicas.listGruposProduto(); + } } diff --git a/apps/api/src/app/politicas/politicas.service.ts b/apps/api/src/app/politicas/politicas.service.ts index 17af67a..3cc487b 100644 --- a/apps/api/src/app/politicas/politicas.service.ts +++ b/apps/api/src/app/politicas/politicas.service.ts @@ -6,6 +6,12 @@ import type { PromocoesResponse, CreatePromocaoBody, UpdatePromocaoBody, + RegrasDescontoResponse, + CreateRegraDescontoBody, + UpdateRegraDescontoBody, + RegrasVigentesResponse, + PromocoesVigentesResponse, + GruposProdutoResponse, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; @@ -158,4 +164,219 @@ export class PoliticasService { await prisma.promocao.delete({ where: { id } }); } + + // ─── Regras de Desconto ───────────────────────────────────────────────────── + + // Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e + // resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código. + private async loadGruposProduto(): Promise< + { + cod_grupo: number | null; + grupo: string | null; + cod_subgrupo: number | null; + subgrupo: string | null; + }[] + > { + 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; + + return prisma.$queryRawUnsafe( + `SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo + FROM vw_produtos + WHERE id_empresa = ${idMatriz} + AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL) + ORDER BY grupo, subgrupo`, + ); + } + + async listGruposProduto(): Promise { + this.assertManager(); + const rows = await this.loadGruposProduto(); + return { + grupos: rows.map((r) => ({ + codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null, + grupo: r.grupo, + codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null, + subgrupo: r.subgrupo, + })), + }; + } + + async listRegras(): Promise { + this.assertManager(); + 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 rows = await prisma.regraDesconto.findMany({ + where: { idEmpresa }, + orderBy: [{ dataFim: 'desc' }, { id: 'desc' }], + }); + + const codigos = [ + ...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)), + ]; + const repRows = + codigos.length > 0 + ? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>( + `SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`, + ) + : []; + const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome])); + + const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null) + ? await this.loadGruposProduto() + : []; + const grupoNameMap = new Map( + grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]), + ); + const subgrupoNameMap = new Map( + grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), + ); + + return { + regras: rows.map((r) => ({ + id: r.id, + descricao: r.descricao, + codGrupo: r.codGrupo, + grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, + codSubgrupo: r.codSubgrupo, + subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, + codVendedor: r.codVendedor, + nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null, + descPct: Number(r.descPct), + dataInicio: r.dataInicio.toISOString().slice(0, 10), + dataFim: r.dataFim.toISOString().slice(0, 10), + ativa: r.ativa, + createdAt: r.createdAt.toISOString(), + createdBy: r.createdBy, + })), + }; + } + + async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> { + this.assertManager(); + 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 userId = this.cls.get('userId') ?? 'system'; + + const r = await prisma.regraDesconto.create({ + data: { + idEmpresa, + descricao: body.descricao, + codGrupo: body.codGrupo ?? null, + codSubgrupo: body.codSubgrupo ?? null, + codVendedor: body.codVendedor ?? null, + descPct: body.descPct, + dataInicio: new Date(body.dataInicio), + dataFim: new Date(body.dataFim), + createdBy: userId, + }, + }); + return { id: r.id }; + } + + async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise { + this.assertManager(); + 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 exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } }); + if (!exists) throw new NotFoundException('Regra de desconto não encontrada'); + + await prisma.regraDesconto.update({ + where: { id }, + data: { + ...(body.descricao !== undefined && { descricao: body.descricao }), + ...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }), + ...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }), + ...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }), + ...(body.descPct !== undefined && { descPct: body.descPct }), + ...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }), + ...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }), + ...(body.ativa !== undefined && { ativa: body.ativa }), + }, + }); + } + + async deleteRegra(id: number): Promise { + this.assertManager(); + 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 exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } }); + if (!exists) throw new NotFoundException('Regra de desconto não encontrada'); + + await prisma.regraDesconto.delete({ where: { id } }); + } + + // Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para + // sinalizar no catálogo que o produto tem condição comercial diferenciada. + async listPromocoesVigentes(): Promise { + 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; + + const hoje = new Date(); + const rows = await prisma.promocao.findMany({ + where: { + idEmpresa: { in: [idEmpresa, idMatriz] }, + ativa: true, + dataInicio: { lte: hoje }, + dataFim: { gte: hoje }, + }, + orderBy: [{ descPct: 'desc' }], + }); + + return { + promocoes: rows.map((p) => ({ + id: p.id, + descricao: p.descricao, + codProduto: p.codProduto, + grpProd: p.grpProd, + descPct: Number(p.descPct), + dataFim: p.dataFim.toISOString().slice(0, 10), + })), + }; + } + + // Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o + // alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas. + async listRegrasVigentes(): Promise { + 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; + const role = this.cls.get('role') ?? 'rep'; + const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10); + + const hoje = new Date(); + const rows = await prisma.regraDesconto.findMany({ + where: { + idEmpresa: { in: [idEmpresa, idMatriz] }, + ativa: true, + dataInicio: { lte: hoje }, + dataFim: { gte: hoje }, + ...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}), + }, + orderBy: [{ descPct: 'desc' }], + }); + + return { + regras: rows.map((r) => ({ + id: r.id, + descricao: r.descricao, + codGrupo: r.codGrupo, + codSubgrupo: r.codSubgrupo, + descPct: Number(r.descPct), + dataFim: r.dataFim.toISOString().slice(0, 10), + })), + }; + } } diff --git a/apps/web/src/cockpits/ger/PoliticasPage.tsx b/apps/web/src/cockpits/ger/PoliticasPage.tsx index 5e7cfbe..064c51f 100644 --- a/apps/web/src/cockpits/ger/PoliticasPage.tsx +++ b/apps/web/src/cockpits/ger/PoliticasPage.tsx @@ -8,6 +8,7 @@ import { InputNumber, Modal, Popconfirm, + Select, Skeleton, Space, Table, @@ -22,7 +23,13 @@ import type { TableColumnsType } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs'; -import type { AlcadaDescontoItem, Promocao } from '@sar/api-interface'; +import type { + AlcadaDescontoItem, + Promocao, + RegraDesconto, + RepStats, + GrupoProdutoItem, +} from '@sar/api-interface'; import { usePoliticasDescontos, usePromocoes, @@ -30,6 +37,12 @@ import { useCreatePromocao, useUpdatePromocao, useDeletePromocao, + useEquipe, + useRegrasDesconto, + useGruposProduto, + useCreateRegraDesconto, + useUpdateRegraDesconto, + useDeleteRegraDesconto, } from '../../lib/queries/gerente'; const { Title, Text } = Typography; @@ -370,6 +383,294 @@ function TabPromocoes() { ); } +// ─── Tab Regras de Desconto ─────────────────────────────────────────────────── + +interface RegraForm { + descricao: string; + codVendedor?: number; + codGrupo?: number; + codSubgrupo?: number; + descPct: number; + periodo: [dayjs.Dayjs, dayjs.Dayjs]; +} + +function TabRegras() { + const { data, isLoading } = useRegrasDesconto(); + const { data: equipe } = useEquipe(); + const { data: gruposData } = useGruposProduto(); + const createMutation = useCreateRegraDesconto(); + const updateMutation = useUpdateRegraDesconto(); + const deleteMutation = useDeleteRegraDesconto(); + const [form] = Form.useForm(); + const [modalOpen, setModalOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [msg, msgCtx] = message.useMessage(); + const grupoSelecionado = Form.useWatch('codGrupo', form); + + const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({ + value: r.codVendedor, + label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`, + })); + + const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? []; + const grupoMap = new Map(); + const subgrupoMap = new Map(); + for (const g of grupoRows) { + if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) { + grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` }); + } + if ( + g.codSubgrupo != null && + (grupoSelecionado == null || g.codGrupo === grupoSelecionado) && + !subgrupoMap.has(g.codSubgrupo) + ) { + subgrupoMap.set(g.codSubgrupo, { + value: g.codSubgrupo, + label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`, + }); + } + } + const grupoOptions = [...grupoMap.values()]; + const subgrupoOptions = [...subgrupoMap.values()]; + + function openCreate() { + setEditTarget(null); + form.resetFields(); + setModalOpen(true); + } + + function openEdit(r: RegraDesconto) { + setEditTarget(r); + form.setFieldsValue({ + descricao: r.descricao, + codVendedor: r.codVendedor ?? undefined, + codGrupo: r.codGrupo ?? undefined, + codSubgrupo: r.codSubgrupo ?? undefined, + descPct: r.descPct, + periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)], + }); + setModalOpen(true); + } + + async function handleSubmit(values: RegraForm) { + const [inicio, fim] = values.periodo; + const body = { + descricao: values.descricao, + codVendedor: values.codVendedor ?? null, + codGrupo: values.codGrupo ?? null, + codSubgrupo: values.codSubgrupo ?? null, + descPct: values.descPct, + dataInicio: inicio.format('YYYY-MM-DD'), + dataFim: fim.format('YYYY-MM-DD'), + }; + try { + if (editTarget) { + await updateMutation.mutateAsync({ id: editTarget.id, body }); + void msg.success('Regra atualizada'); + } else { + await createMutation.mutateAsync(body); + void msg.success('Regra criada'); + } + } catch { + return; // erro já notificado pelo handler global do QueryClient; modal fica aberto + } + setModalOpen(false); + } + + async function handleDelete(id: number) { + try { + await deleteMutation.mutateAsync(id); + } catch { + return; // erro já notificado pelo handler global do QueryClient + } + void msg.success('Regra removida'); + } + + const today = dayjs().format('YYYY-MM-DD'); + + const columns: TableColumnsType = [ + { + title: 'Descricao', + dataIndex: 'descricao', + }, + { + title: 'Representante', + width: 180, + render: (_: unknown, row: RegraDesconto) => + row.codVendedor == null ? ( + Todos + ) : ( + {row.nomeVendedor ?? `Cód. ${row.codVendedor}`} + ), + }, + { + title: 'Grupo / Subgrupo', + width: 220, + render: (_: unknown, row: RegraDesconto) => { + if (row.codGrupo == null && row.codSubgrupo == null) return Todos; + const partes = [ + row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null, + row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null, + ].filter(Boolean); + return {partes.join(' / ')}; + }, + }, + { + title: 'Desconto', + dataIndex: 'descPct', + width: 100, + align: 'right', + render: (v: number) => {v}%, + }, + { + title: 'Vigencia', + width: 200, + render: (_: unknown, row: RegraDesconto) => ( + + {dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')} + + ), + }, + { + title: 'Status', + width: 90, + render: (_: unknown, row: RegraDesconto) => { + if (!row.ativa) return Inativa; + if (row.dataFim < today) return Expirada; + if (row.dataInicio > today) return Futura; + return Ativa; + }, + }, + { + title: '', + width: 100, + render: (_: unknown, row: RegraDesconto) => ( + + + + + + + rowKey="id" + columns={columns} + dataSource={data.regras} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }} + /> + + A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica + o desconto automaticamente nos pedidos durante a vigencia. + + + setModalOpen(false)} + onOk={() => void form.validateFields().then(handleSubmit)} + okText={editTarget ? 'Salvar' : 'Criar'} + confirmLoading={createMutation.isPending || updateMutation.isPending} + width={520} + centered + > +
+ + + + + + form.setFieldValue('codSubgrupo', undefined)} + /> + + +