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' }],
|
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),
|
||||||
})),
|
})),
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user