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