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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 17:31:55 +00:00
parent 78612d59bd
commit ebf68523dd
5 changed files with 198 additions and 3 deletions

View File

@@ -384,12 +384,36 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }], 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 { return {
promocoes: rows.map((p) => ({ promocoes: rows.map((p) => ({
id: p.id, id: p.id,
descricao: p.descricao, descricao: p.descricao,
codProduto: p.codProduto, codProduto: p.codProduto,
nomeProduto: p.codProduto ? (prodNameMap.get(p.codProduto.trim()) ?? null) : null,
grpProd: p.grpProd, grpProd: p.grpProd,
grupo: p.grpProd ? (grupoNameMap.get(p.grpProd.trim()) ?? null) : null,
descPct: Number(p.descPct), descPct: Number(p.descPct),
dataFim: p.dataFim.toISOString().slice(0, 10), dataFim: p.dataFim.toISOString().slice(0, 10),
})), })),
@@ -418,12 +442,25 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }], 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 { return {
regras: rows.map((r) => ({ regras: rows.map((r) => ({
id: r.id, id: r.id,
descricao: r.descricao, descricao: r.descricao,
codGrupo: r.codGrupo, codGrupo: r.codGrupo,
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
codSubgrupo: r.codSubgrupo, codSubgrupo: r.codSubgrupo,
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
descPct: Number(r.descPct), descPct: Number(r.descPct),
dataFim: r.dataFim.toISOString().slice(0, 10), dataFim: r.dataFim.toISOString().slice(0, 10),
})), })),

View File

@@ -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 (
<Flex justify="space-between" align="center" gap={12} wrap>
<Space orientation="vertical" size={0}>
<Space size={8}>
<Tag
color={tipo === 'promocao' ? 'orange' : 'blue'}
style={{ borderRadius: 20, fontSize: 10, fontWeight: 700, marginInlineEnd: 0 }}
>
{tipo === 'promocao' ? 'PROMOÇÃO' : 'DESCONTO LIBERADO'}
</Tag>
<Text strong>{descricao}</Text>
</Space>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{alvo}
</Text>
</Space>
<Space size={8}>
<Tag color="green" className="tabular-nums" style={{ borderRadius: 20, fontWeight: 700 }}>
{descPct}% off
</Tag>
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
até {fmtData(dataFim)}
</Text>
</Space>
</Flex>
);
}
// 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 (
<Card
styles={{ body: expandido ? {} : { display: 'none' } }}
title={
<Flex
justify="space-between"
align="center"
style={{ cursor: 'pointer' }}
onClick={() => setExpandido((v) => !v)}
>
<Space>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--orange)' }} />
Condições Especiais
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
{total}
</Tag>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', fontWeight: 400 }}>
vigentes hoje
</Text>
</Space>
<Button
type="text"
size="small"
aria-label={expandido ? 'Recolher' : 'Expandir'}
icon={
<FontAwesomeIcon
icon={faChevronDown}
style={{
transition: 'transform 0.2s',
transform: expandido ? 'rotate(180deg)' : undefined,
}}
/>
}
/>
</Flex>
}
>
<Flex vertical gap={12}>
{promocoes.map((p: PromocaoVigente, i: number) => (
<div key={`p-${p.id}`}>
{i > 0 && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="promocao"
descricao={p.descricao}
alvo={alvoPromocao(p)}
descPct={p.descPct}
dataFim={p.dataFim}
/>
</div>
))}
{regras.map((r: RegraVigente, i: number) => (
<div key={`r-${r.id}`}>
{(i > 0 || promocoes.length > 0) && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="regra"
descricao={r.descricao}
alvo={alvoRegra(r)}
descPct={r.descPct}
dataFim={r.dataFim}
/>
</div>
))}
</Flex>
</Card>
);
}

View File

@@ -39,6 +39,7 @@ import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard'; import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
import { CondicoesEspeciaisCard } from './CondicoesEspeciaisCard';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -553,6 +554,9 @@ export function RepPainel() {
</Col> </Col>
</Row> </Row>
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
<CondicoesEspeciaisCard />
{/* Metas por Grupo */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
<Card <Card

View File

@@ -11,6 +11,7 @@ import { apiFetch } from '../api-client';
// Regras de desconto vigentes para o usuário logado (rep recebe só as suas). // Regras de desconto vigentes para o usuário logado (rep recebe só as suas).
// Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente. // Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente.
// Refetch curto: regra encerrada/inativada some da tela do rep em até 1 minuto.
export function useRegrasVigentes() { export function useRegrasVigentes() {
return useQuery<RegrasVigentesResponse>({ return useQuery<RegrasVigentesResponse>({
queryKey: ['politicas', 'regras-vigentes'], queryKey: ['politicas', 'regras-vigentes'],
@@ -18,7 +19,8 @@ export function useRegrasVigentes() {
const raw = await apiFetch('/politicas/regras/vigentes'); const raw = await apiFetch('/politicas/regras/vigentes');
return RegrasVigentesResponseSchema.parse(raw); 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() { export function usePromocoesVigentes() {
return useQuery<PromocoesVigentesResponse>({ return useQuery<PromocoesVigentesResponse>({
queryKey: ['politicas', 'promocoes-vigentes'], queryKey: ['politicas', 'promocoes-vigentes'],
@@ -42,6 +45,7 @@ export function usePromocoesVigentes() {
const raw = await apiFetch('/politicas/promocoes/vigentes'); const raw = await apiFetch('/politicas/promocoes/vigentes');
return PromocoesVigentesResponseSchema.parse(raw); return PromocoesVigentesResponseSchema.parse(raw);
}, },
staleTime: 5 * 60 * 1000, staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
}); });
} }

View File

@@ -145,7 +145,9 @@ export const PromocaoVigenteSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
codProduto: z.string().nullable(), codProduto: z.string().nullable(),
nomeProduto: z.string().nullable(),
grpProd: z.string().nullable(), grpProd: z.string().nullable(),
grupo: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
dataFim: z.string(), dataFim: z.string(),
}); });
@@ -161,7 +163,9 @@ export const RegraVigenteSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
codGrupo: z.number().int().nullable(), codGrupo: z.number().int().nullable(),
grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(), codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
dataFim: z.string(), dataFim: z.string(),
}); });