From ebf68523dddf29a9b876c0298e4685132bdf53ac Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 23 Jul 2026 17:31:55 +0000 Subject: [PATCH] feat(api,web): card condicoes especiais no painel do representante Card expansivel no painel do rep com promocoes e regras de desconto vigentes hoje para o representante logado. Endpoints vigentes agora resolvem nomes de produto/grupo/subgrupo; hooks com refetch de 60s para a condicao sumir logo apos encerrar ou inativar. Co-Authored-By: Claude Fable 5 --- .../src/app/politicas/politicas.service.ts | 37 +++++ .../cockpits/rep/CondicoesEspeciaisCard.tsx | 146 ++++++++++++++++++ apps/web/src/cockpits/rep/RepPainel.tsx | 4 + apps/web/src/lib/queries/politicas.ts | 10 +- .../src/lib/politicas.contract.ts | 4 + 5 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx diff --git a/apps/api/src/app/politicas/politicas.service.ts b/apps/api/src/app/politicas/politicas.service.ts index 845cb15..f0a9cb1 100644 --- a/apps/api/src/app/politicas/politicas.service.ts +++ b/apps/api/src/app/politicas/politicas.service.ts @@ -384,12 +384,36 @@ export class PoliticasService { orderBy: [{ descPct: 'desc' }], }); + // Nomes de produto e grupo — UI sempre mostra o nome, nunca só o código + const codProdutos = [ + ...new Set( + rows.map((p) => p.codProduto?.trim()).filter((c): c is string => !!c && c.length > 0), + ), + ]; + const prodRows = + codProdutos.length > 0 + ? await prisma.$queryRawUnsafe<{ codigo: string; descricao: string | null }[]>( + `SELECT TRIM(codigo) AS codigo, TRIM(descricao) AS descricao + FROM vw_produtos + WHERE id_empresa = ${idMatriz} + AND TRIM(codigo) IN (${codProdutos.map((c) => `'${c.replace(/'/g, "''")}'`).join(',')})`, + ) + : []; + const prodNameMap = new Map(prodRows.map((r) => [r.codigo, r.descricao])); + + const grupos = rows.some((p) => p.grpProd != null) ? await this.loadGruposProduto() : []; + const grupoNameMap = new Map( + grupos.filter((g) => g.cod_grupo != null).map((g) => [String(Number(g.cod_grupo)), g.grupo]), + ); + return { promocoes: rows.map((p) => ({ id: p.id, descricao: p.descricao, codProduto: p.codProduto, + nomeProduto: p.codProduto ? (prodNameMap.get(p.codProduto.trim()) ?? null) : null, grpProd: p.grpProd, + grupo: p.grpProd ? (grupoNameMap.get(p.grpProd.trim()) ?? null) : null, descPct: Number(p.descPct), dataFim: p.dataFim.toISOString().slice(0, 10), })), @@ -418,12 +442,25 @@ export class PoliticasService { orderBy: [{ descPct: 'desc' }], }); + // Nomes de grupo/subgrupo — UI sempre mostra o nome, nunca só o código + 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, descPct: Number(r.descPct), dataFim: r.dataFim.toISOString().slice(0, 10), })), diff --git a/apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx b/apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx new file mode 100644 index 0000000..74bc2e1 --- /dev/null +++ b/apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { Button, Card, Divider, Flex, Space, Tag, Typography } from 'antd'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faChevronDown, faTags } from '@fortawesome/free-solid-svg-icons'; +import type { PromocaoVigente, RegraVigente } from '@sar/api-interface'; +import { usePromocoesVigentes, useRegrasVigentes } from '../../lib/queries/politicas'; + +const { Text } = Typography; + +function fmtData(iso: string): string { + return new Date(`${iso}T00:00:00`).toLocaleDateString('pt-BR'); +} + +function alvoPromocao(p: PromocaoVigente): string { + if (p.codProduto) return p.nomeProduto ?? `Produto ${p.codProduto}`; + if (p.grpProd) return `Grupo ${p.grupo ?? p.grpProd}`; + return 'Todos os produtos'; +} + +function alvoRegra(r: RegraVigente): string { + if (r.codGrupo != null) return `Grupo ${r.grupo ?? r.codGrupo}`; + if (r.codSubgrupo != null) return `Subgrupo ${r.subgrupo ?? r.codSubgrupo}`; + return 'Todos os produtos'; +} + +function LinhaCondicao({ + tipo, + descricao, + alvo, + descPct, + dataFim, +}: { + tipo: 'promocao' | 'regra'; + descricao: string; + alvo: string; + descPct: number; + dataFim: string; +}) { + return ( + + + + + {tipo === 'promocao' ? 'PROMOÇÃO' : 'DESCONTO LIBERADO'} + + {descricao} + + + {alvo} + + + + + {descPct}% off + + + até {fmtData(dataFim)} + + + + ); +} + +// Card expansível do painel do rep: promoções e regras de desconto vigentes +// HOJE para o representante logado. Some sozinho quando a condição encerra ou +// é inativada pelo gerente (refetch a cada minuto nos hooks de vigentes). +export function CondicoesEspeciaisCard() { + const { data: promosData } = usePromocoesVigentes(); + const { data: regrasData } = useRegrasVigentes(); + const [expandido, setExpandido] = useState(false); + + const promocoes = promosData?.promocoes ?? []; + const regras = regrasData?.regras ?? []; + const total = promocoes.length + regras.length; + + if (total === 0) return null; + + return ( + setExpandido((v) => !v)} + > + + + Condições Especiais + + {total} + + + vigentes hoje + + +