diff --git a/apps/api/src/app/politicas/politicas.controller.ts b/apps/api/src/app/politicas/politicas.controller.ts index 3afd980..e151a7f 100644 --- a/apps/api/src/app/politicas/politicas.controller.ts +++ b/apps/api/src/app/politicas/politicas.controller.ts @@ -24,6 +24,7 @@ import { type AlcadaDescontosResponse, type PromocoesResponse, type PromocoesVigentesResponse, + type AlcadaRepResponse, type RegrasDescontoResponse, type RegrasVigentesResponse, type GruposProdutoResponse, @@ -61,6 +62,11 @@ export class PoliticasController { return this.politicas.listPromocoesVigentes(); } + @Get('alcada') + minhaAlcada(): Promise { + return this.politicas.minhaAlcada(); + } + @Post('promocoes') createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> { return this.politicas.createPromocao(body as unknown as CreatePromocaoBody); diff --git a/apps/api/src/app/politicas/politicas.service.ts b/apps/api/src/app/politicas/politicas.service.ts index 3cc487b..ecfb543 100644 --- a/apps/api/src/app/politicas/politicas.service.ts +++ b/apps/api/src/app/politicas/politicas.service.ts @@ -11,6 +11,7 @@ import type { UpdateRegraDescontoBody, RegrasVigentesResponse, PromocoesVigentesResponse, + AlcadaRepResponse, GruposProdutoResponse, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; @@ -315,6 +316,25 @@ export class PoliticasService { await prisma.regraDesconto.delete({ where: { id } }); } + // Alçada do próprio usuário logado — o carrinho usa para mostrar o teto de + // desconto por item (codGrupo 0 = default; sem linha 0, o backend assume 5%). + async minhaAlcada(): 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 = parseInt(this.cls.get('userId') ?? '0', 10); + + const rows = await prisma.alcadaDesconto.findMany({ + where: { codVendedor, idEmpresa }, + }); + return { + limites: rows.map((r) => ({ + codGrupo: r.codGrupo, + limitePerc: Number(r.limitePerc), + })), + }; + } + // 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 { diff --git a/apps/web/src/cockpits/rep/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx index 1366ba9..133b1a5 100644 --- a/apps/web/src/cockpits/rep/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rep/NewOrderPage.tsx @@ -45,7 +45,7 @@ import type { } from '@sar/api-interface'; import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients'; import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog'; -import { useCondicoesComerciais } from '../../lib/condicoes-comerciais'; +import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais'; import { CondicaoTag } from '../../components/CondicaoTag'; import { apiFetch } from '../../lib/api-client'; import { enqueueOrder } from '../../lib/offline/order-queue'; @@ -66,6 +66,9 @@ type CartItem = { precoUnitario: number; descontoPerc: number; loteMulVenda: number | null; + // Grupo/subgrupo do produto — usados para calcular o teto de desconto do item + codGrupo: number | null; + codSubgrupo: number | null; }; type SearchParams = { clientId?: string }; @@ -629,17 +632,20 @@ function CatalogBrowserModal({ function MobileCartItem({ item, + teto, onQtyChange, onDiscChange, onRemove, }: { item: CartItem; + teto: number; onQtyChange: (key: string, qty: number) => void; onDiscChange: (key: string, disc: number) => void; onRemove: (key: string) => void; }) { const lote = item.loteMulVenda ?? 1; const invalid = lote > 1 && item.qtd % lote !== 0; + const acimaTeto = item.descontoPerc > teto + 0.001; return (
Desc% - onDiscChange(item.key, n ?? 0)} - /> + + onDiscChange(item.key, n ?? 0)} + /> + +
+ {acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`} +
@@ -969,11 +988,13 @@ function ClientHistoryPanel({ function OrderItemsTable({ items, + tetoDe, onQtyChange, onDiscChange, onRemove, }: { items: CartItem[]; + tetoDe: (item: CartItem) => number; onQtyChange: (key: string, qty: number) => void; onDiscChange: (key: string, disc: number) => void; onRemove: (key: string) => void; @@ -1014,6 +1035,7 @@ function OrderItemsTable({ ( - onDiscChange(row.key, n ?? 0)} - /> - ), + width: 110, + render: (v: number, row: CartItem) => { + const teto = tetoDe(row); + const acimaTeto = v > teto + 0.001; + return ( +
+ + onDiscChange(row.key, n ?? 0)} + /> + +
+ {acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`} +
+
+ ); + }, }, { title: 'Total', @@ -1324,6 +1365,11 @@ export function NewOrderPage() { setClientSearch(''); } + // Teto de desconto por item (alçada + promoção/regra) — aviso em tempo real + const tetoDesconto = useTetoDesconto(); + const tetoDe = (it: CartItem) => + tetoDesconto({ codigo: it.codProduto, codGrupo: it.codGrupo, codSubgrupo: it.codSubgrupo }); + // ── Handlers do carrinho ── // Item entra SEM desconto mesmo quando há promoção/regra vigente (selo // "Condição comercial" avisa o rep): a proposta é carta de negociação — o rep @@ -1350,6 +1396,8 @@ export function NewOrderPage() { precoUnitario: Number(p.vlPreco1), descontoPerc: 0, loteMulVenda: p.loteMulVenda, + codGrupo: p.codGrupo, + codSubgrupo: p.codSubgrupo, }, ]; }); @@ -1373,6 +1421,9 @@ export function NewOrderPage() { precoUnitario: p.ultimoPreco, descontoPerc: 0, loteMulVenda: null, + // TopProduto não traz grupo/subgrupo — teto considera só alçada default + codGrupo: null, + codSubgrupo: null, }, ]; }); @@ -1684,6 +1735,7 @@ export function NewOrderPage() { CondicaoComercial[] const { data: regras } = useRegrasVigentes(); return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras); } + +// Teto de desconto do item — espelha assertAlcadaItens do backend: vale o MAIOR +// entre a alçada do grupo (codGrupo 0 = default; sem linha, 5%) e a melhor +// promoção/regra vigente que alcança o produto. +export function useTetoDesconto(): (p: ProdutoRef) => number { + const condicoesDe = useCondicoesComerciais(); + const { data: alcada } = useMinhaAlcada(); + return (p) => { + const limites = new Map((alcada?.limites ?? []).map((l) => [l.codGrupo, l.limitePerc])); + const base = (p.codGrupo != null ? limites.get(p.codGrupo) : undefined) ?? limites.get(0) ?? 5; + const cond = condicoesDe(p).reduce((max, c) => Math.max(max, c.descPct), 0); + return Math.max(base, cond); + }; +} diff --git a/apps/web/src/lib/queries/politicas.ts b/apps/web/src/lib/queries/politicas.ts index 92b7b23..0dbf280 100644 --- a/apps/web/src/lib/queries/politicas.ts +++ b/apps/web/src/lib/queries/politicas.ts @@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query'; import { RegrasVigentesResponseSchema, PromocoesVigentesResponseSchema, + AlcadaRepResponseSchema, type RegrasVigentesResponse, type PromocoesVigentesResponse, + type AlcadaRepResponse, } from '@sar/api-interface'; import { apiFetch } from '../api-client'; @@ -20,6 +22,18 @@ export function useRegrasVigentes() { }); } +// Alçada do usuário logado — teto de desconto por item no carrinho +export function useMinhaAlcada() { + return useQuery({ + queryKey: ['politicas', 'minha-alcada'], + queryFn: async () => { + const raw = await apiFetch('/politicas/alcada'); + return AlcadaRepResponseSchema.parse(raw); + }, + staleTime: 5 * 60 * 1000, + }); +} + // Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep export function usePromocoesVigentes() { return useQuery({ diff --git a/libs/shared/api-interface/src/lib/politicas.contract.ts b/libs/shared/api-interface/src/lib/politicas.contract.ts index 065e32d..53ec52f 100644 --- a/libs/shared/api-interface/src/lib/politicas.contract.ts +++ b/libs/shared/api-interface/src/lib/politicas.contract.ts @@ -97,6 +97,18 @@ export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.parti }); export type UpdateRegraDescontoBody = z.infer; +// Alçada do usuário logado — usada pelo carrinho para mostrar o teto por item +export const AlcadaRepItemSchema = z.object({ + codGrupo: z.number().int(), + limitePerc: z.number(), +}); +export type AlcadaRepItem = z.infer; + +export const AlcadaRepResponseSchema = z.object({ + limites: z.array(AlcadaRepItemSchema), +}); +export type AlcadaRepResponse = z.infer; + // Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo export const PromocaoVigenteSchema = z.object({ id: z.number().int(),