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