feat(web,api): teto de desconto em tempo real no carrinho do pedido

- GET /politicas/alcada: limites de desconto do proprio usuario logado
- Carrinho (desktop e mobile) mostra "teto: X%" sob o campo de desconto
  de cada item - max(alcada do grupo, promocao, regra vigente), espelho
  do assertAlcadaItens; acima do teto o campo fica vermelho com aviso de
  que a transmissao sera bloqueada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 18:46:58 +00:00
parent 43daf7ad6d
commit 673130d60b
6 changed files with 143 additions and 25 deletions

View File

@@ -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<AlcadaRepResponse> {
return this.politicas.minhaAlcada();
}
@Post('promocoes')
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);

View File

@@ -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<AlcadaRepResponse> {
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<PromocoesVigentesResponse> {

View File

@@ -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 (
<div
@@ -711,6 +717,14 @@ function MobileCartItem({
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
Desc%
</Text>
<Tooltip
title={
acimaTeto
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
: `Teto do item: ${teto}%`
}
color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber
min={0}
max={100}
@@ -719,8 +733,13 @@ function MobileCartItem({
size="small"
style={{ width: 88 }}
addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(item.key, n ?? 0)}
/>
</Tooltip>
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
</div>
</div>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
@@ -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({
<MobileCartItem
key={item.key}
item={item}
teto={tetoDe(item)}
onQtyChange={onQtyChange}
onDiscChange={onDiscChange}
onRemove={onRemove}
@@ -1090,8 +1112,20 @@ function OrderItemsTable({
{
title: 'Desc %',
dataIndex: 'descontoPerc',
width: 100,
render: (v: number, row: CartItem) => (
width: 110,
render: (v: number, row: CartItem) => {
const teto = tetoDe(row);
const acimaTeto = v > teto + 0.001;
return (
<div>
<Tooltip
title={
acimaTeto
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
: `Teto do item: ${teto}%`
}
color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber
min={0}
max={100}
@@ -1100,9 +1134,16 @@ function OrderItemsTable({
size="small"
style={{ width: 76 }}
addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(row.key, n ?? 0)}
/>
),
</Tooltip>
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
</div>
</div>
);
},
},
{
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() {
<OrderItemsTable
items={cart}
tetoDe={tetoDe}
onQtyChange={setQty}
onDiscChange={setDisc}
onRemove={removeItem}

View File

@@ -1,5 +1,5 @@
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
import { usePromocoesVigentes, useRegrasVigentes, useMinhaAlcada } from './queries/politicas';
// Condição comercial vigente que alcança um produto: promoção (por produto ou
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
@@ -62,3 +62,17 @@ export function useCondicoesComerciais(): (p: ProdutoRef) => 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);
};
}

View File

@@ -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<AlcadaRepResponse>({
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<PromocoesVigentesResponse>({

View File

@@ -97,6 +97,18 @@ export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.parti
});
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
// 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<typeof AlcadaRepItemSchema>;
export const AlcadaRepResponseSchema = z.object({
limites: z.array(AlcadaRepItemSchema),
});
export type AlcadaRepResponse = z.infer<typeof AlcadaRepResponseSchema>;
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
export const PromocaoVigenteSchema = z.object({
id: z.number().int(),