- 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>
428 lines
15 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|