Files
sar/apps/web/src/cockpits/rep/OrdersErpPage.tsx
julian a2bab75bad 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>
2026-06-24 20:27:19 +00:00

640 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import {
App,
Button,
Card,
Col,
DatePicker,
Modal,
Input,
Row,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import type { Dayjs } from 'dayjs';
import {
CopyOutlined,
EyeOutlined,
FileTextOutlined,
SearchOutlined,
ClearOutlined,
ShoppingCartOutlined,
} from '@ant-design/icons';
import type { PedidoErpConsultaItem } from '@sar/api-interface';
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
function fmt(v: number | string) {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined) {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
// Status para registros tipo='E' (sig.pedidos entregas)
const SIG_STATUS_ENTREGA: Record<number, { label: string; color: string }> = {
1: { label: 'Pendente', color: 'orange' },
2: { label: 'Liberado', color: 'green' },
3: { label: 'Emitido', color: 'geekblue' },
5: { label: 'Cancelado', color: 'red' },
};
function statusTag(row: PedidoErpConsultaItem) {
const cfg = row.tipo === 'E' ? SIG_STATUS_ENTREGA[row.situa] : undefined;
const label = row.statusDescr ?? cfg?.label ?? String(row.situa);
return (
<Tag
color={cfg?.color ?? 'default'}
style={{ borderRadius: 20, fontWeight: 600, fontSize: 11 }}
>
{label}
</Tag>
);
}
// ─── Modal de detalhes ────────────────────────────────────────────────────────
function ErpConsultaModal({
row,
onClose,
}: {
row: PedidoErpConsultaItem | null;
onClose: () => void;
}) {
const { data: detail, isLoading } = useOrderDetail(
row?.idPedido ? `erp-${row.idPedido}` : undefined,
);
const { message: msg } = App.useApp();
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#94A3B8',
marginBottom: 2,
display: 'block',
};
return (
<Modal
title={
row ? (
<Space size={8}>
<Text strong style={{ color: '#003B8E' }}>
Pedido {row.numeroPedido}
</Text>
<Tag
color={row.tipo === 'P' ? 'blue' : 'cyan'}
style={{ fontWeight: 700, borderRadius: 20, margin: 0 }}
>
{row.tipo === 'P' ? 'Pedido' : 'Entrega'}
</Tag>
{row && statusTag(row)}
</Space>
) : (
'Detalhes'
)
}
open={!!row}
onCancel={onClose}
centered
width={860}
destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
}}
footer={<Button onClick={onClose}>Fechar</Button>}
>
{!row ? null : (
<Space direction="vertical" size={20} style={{ width: '100%' }}>
{/* ── Cabeçalho ── */}
<Card
styles={{ body: { padding: '14px 16px' } }}
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
>
<Row gutter={[16, 10]}>
<Col span={24}>
<span style={label}>Cliente</span>
<Text strong style={{ display: 'block' }}>
{row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`}
</Text>
{row.nomeCliente && row.razaoCliente && (
<Text type="secondary" style={{ fontSize: 12 }}>
{row.nomeCliente}
</Text>
)}
</Col>
<Col span={8}>
<span style={label}>Data</span>
<Text>{fmtDate(row.data)}</Text>
</Col>
{row.dtPrevent && (
<Col span={8}>
<span style={label}>Prev. Entrega</span>
<Text>{fmtDate(row.dtPrevent)}</Text>
</Col>
)}
<Col span={8}>
<span style={label}>Forma Pagto.</span>
<Text>{row.formaPagamento ?? '—'}</Text>
</Col>
<Col span={8}>
<span style={label}>Total</span>
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
{fmt(row.total)}
</Text>
</Col>
{row.numPedSar && (
<Col span={8}>
<span style={label}> SAR</span>
<Text style={{ color: '#64748B' }}>{row.numPedSar}</Text>
</Col>
)}
{row.obs && (
<Col span={24}>
<span style={label}>Observações</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.obs}
</Text>
</Col>
)}
</Row>
</Card>
{/* ── Dados de Entrega / NF (só tipo E) ── */}
{row.tipo === 'E' && (
<Card
styles={{ body: { padding: '14px 16px' } }}
style={{ borderRadius: 8, border: '1px solid #d1fae5', background: '#f0fdf4' }}
>
<Row gutter={[16, 10]}>
<Col span={8}>
<span style={label}> Entrega</span>
<Text strong className="tabular-nums">
{row.numeroEntrega ?? '—'}
</Text>
</Col>
{row.dataEmissao && (
<Col span={8}>
<span style={label}>Data Emissão</span>
<Text>{fmtDate(row.dataEmissao)}</Text>
</Col>
)}
{row.numeroNf && (
<Col span={8}>
<span style={label}>NF</span>
<Space size={4}>
<FileTextOutlined style={{ color: '#003B8E' }} />
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{row.numeroNf}
</Text>
</Space>
</Col>
)}
{row.chaveAcessoNfe && (
<Col span={24}>
<span style={label}>Chave NF-e</span>
<Space size={6} align="start">
<Text
style={{
fontSize: 11,
color: '#475569',
fontFamily: 'monospace',
wordBreak: 'break-all',
lineHeight: 1.5,
}}
>
{row.chaveAcessoNfe}
</Text>
<Tooltip title="Copiar chave">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() =>
void navigator.clipboard
.writeText(row.chaveAcessoNfe ?? '')
.then(() => void msg.success('Chave copiada'))
}
/>
</Tooltip>
</Space>
</Col>
)}
</Row>
</Card>
)}
{/* ── Itens do Pedido ── */}
<div>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
</div>
) : !detail?.itens?.length ? (
<Text type="secondary" style={{ fontSize: 13 }}>
Nenhum item encontrado para este registro.
</Text>
) : (
<Table
rowKey="id"
dataSource={detail.itens}
size="small"
pagination={false}
style={{ borderRadius: 8, border: '1px solid #EBF0F5' }}
columns={[
{
title: 'Produto',
key: 'produto',
render: (_: unknown, item) => (
<Space direction="vertical" size={0}>
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text>
</Space>
),
},
{
title: 'Qtd.',
dataIndex: 'qtd',
width: 70,
align: 'right' as const,
render: (v: string) => <Text className="tabular-nums">{Number(v)}</Text>,
},
{
title: 'Preço Un.',
dataIndex: 'precoUnitario',
width: 120,
align: 'right' as const,
render: (v: string, item) => (
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Text className="tabular-nums">{fmt(Number(v))}</Text>
{Number(item.descontoPerc) > 0 && (
<Text style={{ fontSize: 11, color: '#f59e0b' }}>
-{Number(item.descontoPerc)}%
</Text>
)}
</Space>
),
},
{
title: 'Total',
dataIndex: 'total',
width: 120,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(Number(v))}
</Text>
),
},
]}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3} align="right">
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong style={{ color: '#003B8E' }}>
{fmt(detail.total)}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
)}
</div>
</Space>
)}
</Modal>
);
}
// ─── OrdersErpPage ────────────────────────────────────────────────────────────
export function OrdersErpPage() {
const { message: msg } = App.useApp();
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const [page, setPage] = useState(1);
const [drawerRow, setDrawerRow] = useState<PedidoErpConsultaItem | null>(null);
const limit = 20;
const from = range ? range[0].format('YYYY-MM-DD') : undefined;
const to = range ? range[1].format('YYYY-MM-DD') : undefined;
const { data, isLoading, isFetching } = useOrderErpConsulta({
search: query || undefined,
from,
to,
page,
limit,
});
const rows = data?.data ?? [];
const total = data?.total ?? 0;
const hasFilters = !!query || !!range;
function commitSearch() {
setQuery(search.trim());
setPage(1);
}
function clearFilters() {
setSearch('');
setQuery('');
setRange(null);
setPage(1);
}
const columns: TableColumnsType<PedidoErpConsultaItem> = [
{
title: 'Pedido',
key: 'pedido',
width: 110,
render: (_: unknown, row: PedidoErpConsultaItem) => (
<Space direction="vertical" size={0}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{row.numeroPedido}
</Text>
{row.numPedSar && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{row.numPedSar}</Text>}
</Space>
),
},
{
title: 'Tipo',
dataIndex: 'tipo',
key: 'tipo',
width: 90,
render: (tipo: 'P' | 'E') => (
<Tag color={tipo === 'P' ? 'blue' : 'cyan'} style={{ fontWeight: 700, borderRadius: 20 }}>
{tipo === 'P' ? 'Pedido' : 'Entrega'}
</Tag>
),
},
{
title: 'Data',
dataIndex: 'data',
key: 'data',
width: 110,
render: (v: string) => <Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>,
},
{
title: 'Cliente',
key: 'cliente',
minWidth: 220,
render: (_: unknown, row: PedidoErpConsultaItem) => {
const nome = row.razaoCliente ?? row.nomeCliente;
const sub = row.nomeCliente && row.razaoCliente ? row.nomeCliente : null;
return (
<div>
{nome ? (
<Text strong style={{ fontSize: 13, color: '#1F2937', display: 'block' }}>
{nome}
</Text>
) : (
<Text type="secondary">Cód. {row.idCliente}</Text>
)}
{sub && <Text style={{ fontSize: 11, color: '#64748B', display: 'block' }}>{sub}</Text>}
</div>
);
},
},
{
title: 'Status',
dataIndex: 'situa',
key: 'status',
width: 120,
render: (_: unknown, row: PedidoErpConsultaItem) => statusTag(row),
},
{
title: 'Total',
dataIndex: 'total',
key: 'total',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E', fontSize: 13 }}>
{fmt(v)}
</Text>
),
},
{
title: 'Forma Pagto.',
dataIndex: 'formaPagamento',
key: 'formaPagamento',
width: 140,
render: (v: string | null) => (
<Text style={{ fontSize: 12, color: '#475569' }}>{v ?? '—'}</Text>
),
},
{
title: 'Entrega / NF',
key: 'entrega',
width: 140,
render: (_: unknown, row: PedidoErpConsultaItem) => {
if (row.tipo !== 'E') return <Text type="secondary"></Text>;
return (
<Space direction="vertical" size={2}>
{row.numeroEntrega && (
<Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}>
Entrega {row.numeroEntrega}
</Text>
)}
{row.numeroNf && (
<Space size={4}>
<FileTextOutlined style={{ color: '#003B8E', fontSize: 11 }} />
<Text strong className="tabular-nums" style={{ fontSize: 12, color: '#003B8E' }}>
NF {row.numeroNf}
</Text>
{row.chaveAcessoNfe && (
<Tooltip title={row.chaveAcessoNfe}>
<Button
type="text"
size="small"
icon={<CopyOutlined style={{ fontSize: 10 }} />}
style={{ padding: '0 2px', height: 18, color: '#94A3B8' }}
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard
.writeText(row.chaveAcessoNfe ?? '')
.then(() => void msg.success('Chave copiada'));
}}
/>
</Tooltip>
)}
</Space>
)}
</Space>
);
},
},
{
title: '',
key: 'actions',
width: 56,
render: (_: unknown, row: PedidoErpConsultaItem) => (
<Button
size="small"
type="primary"
icon={<EyeOutlined />}
style={{ borderRadius: 6 }}
disabled={!row.idPedido}
onClick={(e) => {
e.stopPropagation();
setDrawerRow(row);
}}
/>
),
},
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Consulta de Pedidos ERP
</Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Pedidos e entregas no ERP. Apenas leitura.
</p>
</div>
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
marginBottom: 20,
}}
styles={{ body: { padding: '14px 20px' } }}
>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} md={8}>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onPressEnter={commitSearch}
onBlur={commitSearch}
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
placeholder="Nº pedido ou cliente..."
allowClear
onClear={() => {
setSearch('');
setQuery('');
setPage(1);
}}
/>
</Col>
<Col xs={24} md={8}>
<RangePicker
style={{ width: '100%' }}
value={range}
format="DD/MM/YYYY"
allowClear
placeholder={['Data inicial', 'Data final']}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setRange([dates[0], dates[1]]);
} else {
setRange(null);
}
setPage(1);
}}
/>
</Col>
<Col xs={12} md={4}>
<Button
style={{ width: '100%', borderRadius: 6 }}
icon={<ClearOutlined />}
disabled={!hasFilters}
onClick={clearFilters}
>
Limpar
</Button>
</Col>
<Col>
<Text style={{ fontSize: 12, color: '#94A3B8' }}>
{data?.total !== undefined ? `${total.toLocaleString('pt-BR')} registros` : '…'}
</Text>
</Col>
</Row>
</Card>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
</div>
) : rows.length === 0 ? (
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<ShoppingCartOutlined
style={{ fontSize: 56, color: '#D9E2EC', display: 'block', marginBottom: 16 }}
/>
<Text strong style={{ fontSize: 15, display: 'block' }}>
Nenhum registro encontrado
</Text>
<Text type="secondary">Tente alterar os filtros de data ou busca.</Text>
</div>
</Card>
) : (
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 6px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: 0 } }}
>
<Table<PedidoErpConsultaItem>
rowKey={(row) => `${row.tipo}-${row.numeroPedido}-${row.numeroEntrega ?? 0}`}
columns={columns}
dataSource={rows}
size="middle"
loading={isFetching}
scroll={{ x: 1100 }}
onRow={(row) => ({
onClick: () => row.idPedido && setDrawerRow(row),
style: {
background: row.tipo === 'E' ? '#f0fdf4' : '#fff',
verticalAlign: 'top',
cursor: row.idPedido ? 'pointer' : 'default',
},
})}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: false,
showTotal: (t, [s, e]) => `Mostrando ${s}${e} de ${t} registros`,
onChange: (p) => setPage(p),
style: { padding: '12px 24px' },
}}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
)}
<ErpConsultaModal row={drawerRow} onClose={() => setDrawerRow(null)} />
<style>{`
.ant-table-row:hover td { background: inherit !important; filter: brightness(0.97); }
`}</style>
</div>
);
}