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 + + + + } + /> + + } + > + + {promocoes.map((p: PromocaoVigente, i: number) => ( + + {i > 0 && } + + + ))} + {regras.map((r: RegraVigente, i: number) => ( + + {(i > 0 || promocoes.length > 0) && } + + + ))} + + + ); +} diff --git a/apps/web/src/cockpits/rep/RepPainel.tsx b/apps/web/src/cockpits/rep/RepPainel.tsx index 26b7003..8194846 100644 --- a/apps/web/src/cockpits/rep/RepPainel.tsx +++ b/apps/web/src/cockpits/rep/RepPainel.tsx @@ -39,6 +39,7 @@ import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar import { SITUA_LABEL } from '@sar/api-interface'; import { useRepDashboard } from '../../lib/queries/dashboard'; import { useCurrentUser } from '../../lib/queries/auth'; +import { CondicoesEspeciaisCard } from './CondicoesEspeciaisCard'; ChartJS.register( CategoryScale, @@ -553,6 +554,9 @@ export function RepPainel() { + {/* Condições especiais vigentes hoje (promoções + descontos liberados) */} + + {/* Metas por Grupo */} {metasPorGrupo.length > 0 && ( ({ queryKey: ['politicas', 'regras-vigentes'], @@ -18,7 +19,8 @@ export function useRegrasVigentes() { const raw = await apiFetch('/politicas/regras/vigentes'); return RegrasVigentesResponseSchema.parse(raw); }, - staleTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, }); } @@ -34,7 +36,8 @@ export function useMinhaAlcada() { }); } -// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep +// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep. +// Refetch curto: promoção encerrada/inativada some da tela do rep em até 1 minuto. export function usePromocoesVigentes() { return useQuery({ queryKey: ['politicas', 'promocoes-vigentes'], @@ -42,6 +45,7 @@ export function usePromocoesVigentes() { const raw = await apiFetch('/politicas/promocoes/vigentes'); return PromocoesVigentesResponseSchema.parse(raw); }, - staleTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, }); } diff --git a/libs/shared/api-interface/src/lib/politicas.contract.ts b/libs/shared/api-interface/src/lib/politicas.contract.ts index b521f62..4ba28f2 100644 --- a/libs/shared/api-interface/src/lib/politicas.contract.ts +++ b/libs/shared/api-interface/src/lib/politicas.contract.ts @@ -145,7 +145,9 @@ export const PromocaoVigenteSchema = z.object({ id: z.number().int(), descricao: z.string(), codProduto: z.string().nullable(), + nomeProduto: z.string().nullable(), grpProd: z.string().nullable(), + grupo: z.string().nullable(), descPct: z.number(), dataFim: z.string(), }); @@ -161,7 +163,9 @@ export const RegraVigenteSchema = z.object({ id: z.number().int(), descricao: z.string(), codGrupo: z.number().int().nullable(), + grupo: z.string().nullable(), codSubgrupo: z.number().int().nullable(), + subgrupo: z.string().nullable(), descPct: z.number(), dataFim: z.string(), });