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

@@ -1,10 +1,12 @@
import { useState } from 'react';
import { Table, Input, Select, Space, Typography, Tag } from 'antd';
import { Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
import { EyeOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
import { ProductDetailDrawer } from './ProductDetailDrawer';
const { Title } = Typography;
const { Title, Text } = Typography;
const { Search } = Input;
function fmtPrice(v: string | null | undefined): string {
@@ -12,77 +14,105 @@ function fmtPrice(v: string | null | undefined): string {
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
}
const columns: TableColumnsType<ProdutoSummary> = [
{
title: 'Código',
dataIndex: 'codigo',
width: 110,
render: (v: string) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.trim()}</span>,
},
{
title: 'Descrição',
dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => (
<div>
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
</div>
),
},
{
title: 'Und',
dataIndex: 'unidade',
width: 60,
align: 'center',
render: (v: string | null) => v ?? '—',
},
{
title: 'Marca',
dataIndex: 'marca',
width: 130,
render: (v: string | null) => (v ? <Tag>{v.trim()}</Tag> : null),
},
{
title: 'Preço',
dataIndex: 'vlPreco1',
width: 120,
align: 'right',
render: (v: string) => (
<span style={{ fontWeight: 600, color: Number(v) > 0 ? '#389e0d' : '#999' }}>
{fmtPrice(v)}
</span>
),
},
{
title: 'Estoque',
dataIndex: 'qtdEstoque',
width: 90,
align: 'right',
render: (v: string | null) => {
if (v == null) return '—';
const n = Number(v);
return (
<span style={{ color: n > 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')}</span>
);
function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoSummary> {
return [
{
title: 'digo',
dataIndex: 'codigo',
width: 110,
render: (v: string) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.trim()}</span>,
},
},
];
{
title: 'Descrição',
dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => (
<div>
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
</div>
),
},
{
title: 'Und',
dataIndex: 'unidade',
width: 60,
align: 'center',
render: (v: string | null) => v ?? '—',
},
{
title: 'Marca',
dataIndex: 'marca',
width: 130,
render: (v: string | null) => (v ? <Tag>{v.trim()}</Tag> : null),
},
{
title: 'Preço',
dataIndex: 'vlPreco1',
width: 120,
align: 'right',
render: (v: string) => (
<span style={{ fontWeight: 600, color: Number(v) > 0 ? '#389e0d' : '#999' }}>
{fmtPrice(v)}
</span>
),
},
{
title: 'Estoque',
dataIndex: 'qtdEstoque',
width: 90,
align: 'right',
render: (v: string | null) => {
if (v == null) return '—';
const n = Number(v);
return (
<span style={{ color: n > 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')}</span>
);
},
},
{
title: '',
key: 'actions',
width: 48,
align: 'center',
render: (_: unknown, row: ProdutoSummary) => (
<Tooltip title="Ver detalhes">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => onDetail(row.idErp)}
/>
</Tooltip>
),
},
];
}
export function CatalogPage() {
const [q, setQ] = useState('');
const [idPauta, setIdPauta] = useState<number | undefined>();
const [page, setPage] = useState(1);
const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null);
const limit = 50;
const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 16 }}>
Catálogo de Produtos
</Title>
const columns = buildColumns((id) => setSelectedIdErp(id));
return (
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
{/* ── Cabeçalho ────────────────────────────────────────────────── */}
<div style={{ marginBottom: 20 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Catálogo de Produtos
</Title>
<Text style={{ color: '#64748B', fontSize: 14 }}>
Consulte produtos, preços por pauta e disponibilidade de estoque.
</Text>
</div>
{/* ── Filtros ──────────────────────────────────────────────────── */}
<Space style={{ marginBottom: 16 }} wrap>
<Search
placeholder="Buscar por código ou descrição..."
@@ -129,7 +159,13 @@ export function CatalogPage() {
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
onChange: (p) => setPage(p),
}}
onRow={(row) => ({
style: { cursor: 'pointer' },
onDoubleClick: () => setSelectedIdErp(row.idErp),
})}
/>
<ProductDetailDrawer idErp={selectedIdErp} onClose={() => setSelectedIdErp(null)} />
</div>
);
}