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

@@ -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 { 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() {
</Col>
</Row>
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
<CondicoesEspeciaisCard />
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<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).
// 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() {
return useQuery<RegrasVigentesResponse>({
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<PromocoesVigentesResponse>({
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,
});
}