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:
@@ -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),
|
||||
})),
|
||||
|
||||
146
apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx
Normal file
146
apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user