Files
sar/apps/web/src/cockpits/rep/ProductDetailDrawer.tsx
julian f543b8ac7f feat(politicas): regras de desconto por grupo/subgrupo/rep e condicoes comerciais no catalogo
- Nova tabela sar.regras_desconto (grupo, subgrupo, representante, % e
  vigencia; campos nulos = todos) com migration idempotente, espelho no
  sar-erp-schema.sql e GRANT no provision-client
- CRUD manager-only em /politicas/regras + aba "Regras de Desconto" na
  tela de Politicas Comerciais (selects com nomes de rep/grupo/subgrupo
  via /politicas/grupos-produto)
- GET /politicas/regras/vigentes (rep ve so as que o alcancam) e
  GET /politicas/promocoes/vigentes para o catalogo
- Regra vigente pre-aplica o desconto no carrinho do novo pedido
- Selos "Promocao"/"Cond. especial" na busca de produto, catalogo do
  pedido e pagina de catalogo; secao "Condicoes Comerciais" no modal de
  detalhe do produto (descricao, % e validade)
- Empty imageStyle deprecado -> styles.image no carrinho

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:24:21 +00:00

428 lines
15 KiB
TypeScript

import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
import {
BarcodeOutlined,
InboxOutlined,
PercentageOutlined,
TagsOutlined,
} from '@ant-design/icons';
import type { PautaPreco } from '@sar/api-interface';
import { useProdutoDetail } from '../../lib/queries/catalog';
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
const { Text, Title } = Typography;
function fmtPrice(v: string | null | undefined): string {
const n = Number(v ?? 0);
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
}
function fmtNum(v: string | null | undefined, decimals = 3): string {
const n = Number(v ?? 0);
return n > 0
? n.toLocaleString('pt-BR', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
: '—';
}
// ─── Bloco de seção ───────────────────────────────────────────────────────────
function Section({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
<Text
strong
style={{
fontSize: 13,
color: '#374151',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{title}
</Text>
</div>
{children}
</div>
);
}
// ─── Campo label/valor ────────────────────────────────────────────────────────
function Field({
label,
value,
span = 1,
}: {
label: string;
value: React.ReactNode;
span?: number;
}) {
return (
<div style={{ gridColumn: span > 1 ? 'span 2' : undefined, marginBottom: 12 }}>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 2 }}>
{label}
</Text>
<Text style={{ fontSize: 13, color: '#1F2937' }}>{value ?? '—'}</Text>
</div>
);
}
// ─── Card de preço ────────────────────────────────────────────────────────────
function PrecoCard({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
const n = Number(value ?? 0);
const empty = n <= 0;
return (
<div
style={{
padding: '10px 14px',
borderRadius: 8,
background: highlight && !empty ? '#F0FDF4' : '#F8FAFC',
border: `1px solid ${highlight && !empty ? '#BBF7D0' : '#EBF0F5'}`,
textAlign: 'center',
}}
>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
{label}
</Text>
<Text
strong
style={{
fontSize: 15,
color: empty ? '#CBD5E1' : highlight ? '#16A34A' : '#1F2937',
fontVariantNumeric: 'tabular-nums',
}}
>
{fmtPrice(value)}
</Text>
</div>
);
}
// ─── Lista de pautas ──────────────────────────────────────────────────────────
function PautaPrecoList({ items }: { items: PautaPreco[] }) {
if (items.length === 0) {
return (
<div
style={{
padding: '24px 16px',
textAlign: 'center',
background: '#F8FAFC',
borderRadius: 8,
border: '1px dashed #E2E8F0',
}}
>
<Text type="secondary" style={{ fontSize: 13 }}>
Produto não está em nenhuma pauta ativa.
</Text>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{items.map((item) => (
<div
key={item.idPauta}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderRadius: 8,
background: '#F8FAFC',
border: '1px solid #EBF0F5',
}}
>
<div>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
Pauta {item.codigo}
</Text>
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
{item.descricao}
</Text>
</div>
<Text
strong
style={{ fontSize: 16, color: '#16A34A', fontVariantNumeric: 'tabular-nums' }}
>
{fmtPrice(item.preco)}
</Text>
</div>
))}
</div>
);
}
// ─── Condições comerciais vigentes ────────────────────────────────────────────
function fmtData(iso: string): string {
const [y, m, d] = iso.split('-');
return `${d}/${m}/${y}`;
}
function CondicoesList({ conds }: { conds: CondicaoComercial[] }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{conds.map((c, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '10px 14px',
borderRadius: 8,
background: c.tipo === 'promocao' ? '#FFFBE6' : '#F0F5FF',
border: `1px solid ${c.tipo === 'promocao' ? '#FFE58F' : '#ADC6FF'}`,
}}
>
<div style={{ minWidth: 0 }}>
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
{c.tipo === 'promocao' ? 'Promoção' : 'Condição especial'} · válida até{' '}
{fmtData(c.dataFim)}
</Text>
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
{c.descricao}
</Text>
</div>
<Text
strong
style={{
fontSize: 16,
color: c.tipo === 'promocao' ? '#D48806' : '#2F54EB',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{c.descPct.toLocaleString('pt-BR')}% desc.
</Text>
</div>
))}
</div>
);
}
// ─── Modal principal ──────────────────────────────────────────────────────────
interface Props {
idErp: number | null;
onClose: () => void;
}
export function ProductDetailDrawer({ idErp, onClose }: Props) {
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
const condicoesDe = useCondicoesComerciais();
const conds = data ? condicoesDe(data) : [];
return (
<Modal
open={idErp != null}
onCancel={onClose}
footer={null}
width={1100}
centered
title={null}
styles={{ body: { padding: 0 } }}
>
{isLoading || !data ? (
<div style={{ padding: 32 }}>
<Skeleton active paragraph={{ rows: 14 }} />
</div>
) : (
<>
{/* ── Cabeçalho ──────────────────────────────────────────── */}
<div
style={{
padding: '24px 32px 20px',
borderBottom: '1px solid #F1F5F9',
background: '#FAFBFC',
borderRadius: '8px 8px 0 0',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 12, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
Código {data.codigo.trim()}
{data.referencia ? ` · Ref. ${data.referencia.trim()}` : ''}
</Text>
<Title level={4} style={{ margin: 0, color: '#1F2937', lineHeight: 1.3 }}>
{data.descricao.trim()}
</Title>
{data.descricaoDetalhada && (
<Text style={{ fontSize: 13, color: '#64748B', display: 'block', marginTop: 4 }}>
{data.descricaoDetalhada.trim()}
</Text>
)}
</div>
<div
style={{
display: 'flex',
gap: 8,
flexShrink: 0,
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}
>
<CondicaoTag conds={conds} />
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
{data.unidade && <Tag>{data.unidade}</Tag>}
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
</div>
</div>
</div>
{/* ── Corpo ──────────────────────────────────────────────── */}
<div style={{ padding: '28px 32px', maxHeight: '65vh', overflowY: 'auto' }}>
<Row gutter={40}>
{/* Coluna esquerda */}
<Col xs={24} md={14}>
{/* Condições comerciais vigentes */}
{conds.length > 0 && (
<Section icon={<PercentageOutlined />} title="Condições Comerciais">
<CondicoesList conds={conds} />
</Section>
)}
{/* Classificação */}
<Section icon={<TagsOutlined />} title="Classificação">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
<Field label="Grupo" value={data.grupo?.trim() ?? '—'} />
<Field label="Subgrupo" value={data.subgrupo?.trim() ?? '—'} />
</div>
</Section>
{/* Preços Base */}
<Section icon={<TagsOutlined />} title="Preços Base">
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 8,
marginBottom: 8,
}}
>
<PrecoCard label="Preço 1" value={data.vlPreco1} highlight />
<PrecoCard label="Preço 2" value={data.vlPreco2 ?? '0'} />
<PrecoCard label="Preço 3" value={data.vlPreco3 ?? '0'} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
<PrecoCard label="Promocional" value={data.precoPromocional ?? '0'} />
<PrecoCard label="Com IPI" value={data.precoComIpi ?? '0'} />
<div
style={{
padding: '10px 14px',
borderRadius: 8,
background: '#F8FAFC',
border: '1px solid #EBF0F5',
textAlign: 'center',
}}
>
<Text
style={{
fontSize: 11,
color: '#94A3B8',
display: 'block',
marginBottom: 4,
}}
>
Alíq. IPI
</Text>
<Text strong style={{ fontSize: 15, color: '#1F2937' }}>
{data.aliqIpi ? `${Number(data.aliqIpi).toLocaleString('pt-BR')}%` : '—'}
</Text>
</div>
</div>
</Section>
{/* Estoque & Logística */}
<Section icon={<InboxOutlined />} title="Estoque &amp; Logística">
<Row gutter={16}>
<Col span={6}>
<Statistic
title="Estoque"
value={data.qtdEstoque != null ? Number(data.qtdEstoque) : undefined}
valueStyle={{
fontSize: 22,
color:
data.qtdEstoque == null
? '#CBD5E1'
: Number(data.qtdEstoque) > 0
? '#1F2937'
: '#EF4444',
}}
formatter={(v) => (v != null ? Number(v).toLocaleString('pt-BR') : '—')}
/>
</Col>
<Col span={6}>
<Statistic
title="Lote múlt."
value={data.loteMulVenda ?? undefined}
valueStyle={{ fontSize: 22, color: '#1F2937' }}
formatter={(v) => (v != null ? String(v) : '—')}
/>
</Col>
<Col span={6}>
<Statistic
title="Peso líq. (kg)"
value={data.pesoLiquido ?? undefined}
valueStyle={{ fontSize: 22, color: '#1F2937' }}
formatter={(v) => fmtNum(String(v))}
/>
</Col>
<Col span={6}>
<Statistic
title="Qtd. volume"
value={data.qtdVolume ?? undefined}
valueStyle={{ fontSize: 22, color: '#1F2937' }}
formatter={(v) => fmtNum(String(v), 0)}
/>
</Col>
</Row>
</Section>
</Col>
{/* Divisor vertical */}
<Col md={0} xs={24}>
<Divider style={{ margin: '4px 0 24px' }} />
</Col>
{/* Coluna direita — Pautas */}
<Col xs={24} md={10}>
<Section
icon={<BarcodeOutlined />}
title={`Preços por Pauta (${data.pautaPrecos.length})`}
>
<PautaPrecoList items={data.pautaPrecos} />
</Section>
</Col>
</Row>
</div>
</>
)}
</Modal>
);
}