feat(web+api): catálogo com detalhe do produto, preços por pauta e consulta de pedidos ERP

- Catálogo: modal de detalhe centralizado (1100px) com identificação, estoque,
  preços base e lista de preços por pauta do representante (DISTINCT para evitar duplicatas)
- Contrato: loteMulVenda promovido para ProdutoSummarySchema; PautaPrecoSchema adicionado
- Pedido novo: valida lote múltiplo de venda — qtd inicial = lote, step = lote,
  InputNumber em erro quando inválido, alerta e bloqueio do submit
- Consulta ERP: tela OrdersErpPage com listagem de pedidos do ERP (P/E), contrato
  PedidoErpConsulta, endpoint no orders.controller, rota e sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:27:19 +00:00
parent 0858e1e941
commit a2bab75bad
15 changed files with 1804 additions and 492 deletions

View File

@@ -0,0 +1,360 @@
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
import { BarcodeOutlined, InboxOutlined, TagsOutlined } from '@ant-design/icons';
import type { PautaPreco } from '@sar/api-interface';
import { useProdutoDetail } from '../../lib/queries/catalog';
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>
);
}
// ─── Modal principal ──────────────────────────────────────────────────────────
interface Props {
idErp: number | null;
onClose: () => void;
}
export function ProductDetailDrawer({ idErp, onClose }: Props) {
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
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',
}}
>
{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}>
{/* 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>
);
}