- 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>
640 lines
21 KiB
TypeScript
640 lines
21 KiB
TypeScript
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}>Nº 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}>Nº 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>
|
||
);
|
||
}
|