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:
@@ -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: '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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Modal,
|
||||
Dropdown,
|
||||
Grid,
|
||||
Row,
|
||||
@@ -397,9 +397,9 @@ function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CustomerDetailsDrawer ────────────────────────────────────────────────────
|
||||
// ─── CustomerDetailsModal ────────────────────────────────────────────────────
|
||||
|
||||
function CustomerDetailsDrawer({
|
||||
function CustomerDetailsModal({
|
||||
summary,
|
||||
onClose,
|
||||
onAnalyze,
|
||||
@@ -439,7 +439,7 @@ function CustomerDetailsDrawer({
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
@@ -448,14 +448,29 @@ function CustomerDetailsDrawer({
|
||||
<CustomerStatusBadge status={summary.activityStatus} />
|
||||
</Space>
|
||||
}
|
||||
open
|
||||
onClose={onClose}
|
||||
width={520}
|
||||
placement="right"
|
||||
styles={{ body: { padding: '16px 24px' } }}
|
||||
open={!!summary}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
width={860}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
|
||||
}}
|
||||
footer={
|
||||
<Space wrap>
|
||||
<Button onClick={onClose}>Fechar</Button>
|
||||
<Button
|
||||
icon={<WhatsAppOutlined />}
|
||||
style={{
|
||||
background: '#25D366',
|
||||
borderColor: '#25D366',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
|
||||
>
|
||||
WhatsApp
|
||||
</Button>
|
||||
<Button icon={<BarChartOutlined />} onClick={onAnalyze}>
|
||||
Analisar
|
||||
</Button>
|
||||
@@ -483,135 +498,141 @@ function CustomerDetailsDrawer({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
{/* Identificação */}
|
||||
<Card
|
||||
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||
styles={{ body: { padding: '14px 16px' } }}
|
||||
>
|
||||
<Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}>
|
||||
{summary.nome}
|
||||
</Text>
|
||||
{summary.razao && (
|
||||
<Text style={{ fontSize: 13, color: '#64748B', display: 'block', marginTop: 2 }}>
|
||||
{summary.razao}
|
||||
</Text>
|
||||
)}
|
||||
{summary.cgcpf && (
|
||||
<Text style={{ fontSize: 13, color: '#94A3B8', fontFamily: 'monospace' }}>
|
||||
{summary.cgcpf}
|
||||
</Text>
|
||||
)}
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col span={16}>
|
||||
<Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}>
|
||||
{summary.nome}
|
||||
</Text>
|
||||
{summary.razao && (
|
||||
<Text style={{ fontSize: 13, color: '#64748B', display: 'block', marginTop: 2 }}>
|
||||
{summary.razao}
|
||||
</Text>
|
||||
)}
|
||||
{summary.cgcpf && (
|
||||
<Text style={{ fontSize: 13, color: '#94A3B8', fontFamily: 'monospace' }}>
|
||||
{summary.cgcpf}
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={8} style={{ textAlign: 'right' }}>
|
||||
<CustomerStatusBadge status={summary.activityStatus} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Dados cadastrais */}
|
||||
<div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#64748B',
|
||||
display: 'block',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
Dados Cadastrais
|
||||
</Text>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={12}>
|
||||
<span style={label}>Telefone</span>
|
||||
<Space size={4}>
|
||||
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 13 }}>{phone}</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<span style={label}>E-mail</span>
|
||||
<Space size={4}>
|
||||
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>{summary.email ?? '—'}</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
{loadingDetail ? (
|
||||
<Col span={24}>
|
||||
<Spin size="small" />
|
||||
</Col>
|
||||
) : (
|
||||
<>
|
||||
{endereco !== '—' && (
|
||||
<Col span={24}>
|
||||
<span style={label}>Endereço</span>
|
||||
<Text style={{ fontSize: 13 }}>{endereco}</Text>
|
||||
</Col>
|
||||
)}
|
||||
{d?.inscricaoEstadual && (
|
||||
<Col span={12}>
|
||||
<span style={label}>Insc. Estadual</span>
|
||||
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
|
||||
</Col>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
|
||||
{/* Dados comerciais */}
|
||||
<div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#64748B',
|
||||
display: 'block',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
Dados Comerciais
|
||||
</Text>
|
||||
<Row gutter={[12, 12]}>
|
||||
{limiteFormatado !== '—' && (
|
||||
<Col span={12}>
|
||||
<span style={label}>Limite de Crédito</span>
|
||||
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
|
||||
{limiteFormatado}
|
||||
</Text>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={12}>
|
||||
<span style={label}>Representante</span>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
|
||||
{summary.nomeVendedor && (
|
||||
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
|
||||
(cód. {summary.codVendedor})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
{summary.dtUltimaCompra && (
|
||||
<Col span={12}>
|
||||
<span style={label}>Última Compra</span>
|
||||
{/* Dados cadastrais + comerciais em 2 colunas */}
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col span={12}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#64748B',
|
||||
display: 'block',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
Dados Cadastrais
|
||||
</Text>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<span style={label}>Telefone</span>
|
||||
<Space size={4}>
|
||||
<CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
{fmtDate(summary.dtUltimaCompra)}
|
||||
{dias !== null && (
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8', marginLeft: 4 }}>
|
||||
({dias} dias)
|
||||
</Text>
|
||||
)}
|
||||
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 13 }}>{phone}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
<div>
|
||||
<span style={label}>E-mail</span>
|
||||
<Space size={4}>
|
||||
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>
|
||||
{summary.email ?? '—'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
{loadingDetail ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<>
|
||||
{endereco !== '—' && (
|
||||
<div>
|
||||
<span style={label}>Endereço</span>
|
||||
<Text style={{ fontSize: 13 }}>{endereco}</Text>
|
||||
</div>
|
||||
)}
|
||||
{d?.inscricaoEstadual && (
|
||||
<div>
|
||||
<span style={label}>Insc. Estadual</span>
|
||||
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.07em',
|
||||
textTransform: 'uppercase',
|
||||
color: '#64748B',
|
||||
display: 'block',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
Dados Comerciais
|
||||
</Text>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
{limiteFormatado !== '—' && (
|
||||
<div>
|
||||
<span style={label}>Limite de Crédito</span>
|
||||
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
|
||||
{limiteFormatado}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span style={label}>Representante</span>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
|
||||
{summary.nomeVendedor && (
|
||||
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
|
||||
(cód. {summary.codVendedor})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
{summary.dtUltimaCompra && (
|
||||
<div>
|
||||
<span style={label}>Última Compra</span>
|
||||
<Space size={4}>
|
||||
<CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
{fmtDate(summary.dtUltimaCompra)}
|
||||
{dias !== null && (
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8', marginLeft: 4 }}>
|
||||
({dias} dias)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Últimos pedidos */}
|
||||
{(loadingOrders || orders.length > 0) && (
|
||||
@@ -634,7 +655,7 @@ function CustomerDetailsDrawer({
|
||||
{loadingOrders ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{orders.slice(0, 5).map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
@@ -706,29 +727,14 @@ function CustomerDetailsDrawer({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
block
|
||||
icon={<WhatsAppOutlined />}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: '#25D366',
|
||||
borderColor: '#25D366',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
|
||||
>
|
||||
Enviar WhatsApp
|
||||
</Button>
|
||||
</Space>
|
||||
</Drawer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CustomerAnalysisDrawer ───────────────────────────────────────────────────
|
||||
// ─── CustomerAnalysisModal ───────────────────────────────────────────────────
|
||||
|
||||
function CustomerAnalysisDrawer({
|
||||
function CustomerAnalysisModal({
|
||||
summary,
|
||||
onClose,
|
||||
}: {
|
||||
@@ -755,25 +761,55 @@ function CustomerAnalysisDrawer({
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<BarChartOutlined />
|
||||
<Text strong>Análise Comercial</Text>
|
||||
<Text strong>Análise Comercial — {summary.nome}</Text>
|
||||
</Space>
|
||||
}
|
||||
open={!!summary}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
width={720}
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: '20px 24px' } }}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<WhatsAppOutlined />}
|
||||
style={{
|
||||
background: '#25D366',
|
||||
borderColor: '#25D366',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`,
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
WhatsApp
|
||||
</Button>
|
||||
<Button
|
||||
icon={<MailOutlined />}
|
||||
style={{ borderRadius: 8 }}
|
||||
onClick={() => window.open(`mailto:${summary.email ?? ''}`, '_blank')}
|
||||
>
|
||||
E-mail
|
||||
</Button>
|
||||
<Button onClick={onClose} style={{ borderRadius: 8 }}>
|
||||
Fechar
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
open
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
placement="right"
|
||||
styles={{ body: { padding: '16px 24px' } }}
|
||||
>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 17, color: '#1F2937', display: 'block' }}>
|
||||
{summary.nome}
|
||||
</Text>
|
||||
<Space size={8} style={{ marginTop: 4 }}>
|
||||
<Space size={8}>
|
||||
<CustomerStatusBadge status={summary.activityStatus} />
|
||||
{summary.cgcpf && (
|
||||
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.cgcpf}</Text>
|
||||
@@ -875,39 +911,8 @@ function CustomerAnalysisDrawer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
icon={<WhatsAppOutlined />}
|
||||
style={{
|
||||
background: '#25D366',
|
||||
borderColor: '#25D366',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`,
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
WhatsApp
|
||||
</Button>
|
||||
<Button
|
||||
icon={<MailOutlined />}
|
||||
style={{ borderRadius: 8 }}
|
||||
onClick={() => window.open(`mailto:${summary.email ?? ''}`, '_blank')}
|
||||
>
|
||||
E-mail
|
||||
</Button>
|
||||
<Button onClick={onClose} style={{ borderRadius: 8 }}>
|
||||
Fechar
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1535,9 +1540,9 @@ export function ClientsPage() {
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* ── Drawers ───────────────────────────────────────────────────── */}
|
||||
{/* ── Modais ────────────────────────────────────────────────────── */}
|
||||
{detailSummary && (
|
||||
<CustomerDetailsDrawer
|
||||
<CustomerDetailsModal
|
||||
summary={detailSummary}
|
||||
onClose={() => setDetailSummary(null)}
|
||||
onAnalyze={() => {
|
||||
@@ -1547,10 +1552,7 @@ export function ClientsPage() {
|
||||
/>
|
||||
)}
|
||||
{analysisSummary && (
|
||||
<CustomerAnalysisDrawer
|
||||
summary={analysisSummary}
|
||||
onClose={() => setAnalysisSummary(null)}
|
||||
/>
|
||||
<CustomerAnalysisModal summary={analysisSummary} onClose={() => setAnalysisSummary(null)} />
|
||||
)}
|
||||
|
||||
{/* FAB mobile */}
|
||||
|
||||
@@ -53,6 +53,7 @@ type CartItem = {
|
||||
qtd: number;
|
||||
precoUnitario: number;
|
||||
descontoPerc: number;
|
||||
loteMulVenda: number | null;
|
||||
};
|
||||
|
||||
type SearchParams = { clientId?: string };
|
||||
@@ -241,17 +242,36 @@ function OrderItemsTable({
|
||||
{
|
||||
title: 'Qtd',
|
||||
dataIndex: 'qtd',
|
||||
width: 100,
|
||||
render: (v: number, row: CartItem) => (
|
||||
<InputNumber
|
||||
min={0.001}
|
||||
step={1}
|
||||
value={v}
|
||||
size="small"
|
||||
style={{ width: 76 }}
|
||||
onChange={(n) => onQtyChange(row.key, n ?? 1)}
|
||||
/>
|
||||
),
|
||||
width: 120,
|
||||
render: (v: number, row: CartItem) => {
|
||||
const lote = row.loteMulVenda ?? 1;
|
||||
const invalid = lote > 1 && v % lote !== 0;
|
||||
return (
|
||||
<div>
|
||||
<Tooltip
|
||||
title={
|
||||
invalid ? `Deve ser múltiplo de ${lote}` : lote > 1 ? `Lote: ${lote}` : undefined
|
||||
}
|
||||
color={invalid ? 'red' : 'blue'}
|
||||
>
|
||||
<InputNumber
|
||||
min={lote}
|
||||
step={lote}
|
||||
value={v}
|
||||
size="small"
|
||||
style={{ width: 84 }}
|
||||
status={invalid ? 'error' : undefined}
|
||||
onChange={(n) => onQtyChange(row.key, n ?? lote)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{lote > 1 && (
|
||||
<div style={{ fontSize: 10, color: invalid ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
|
||||
lote: {lote}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Un.',
|
||||
@@ -451,14 +471,19 @@ export function NewOrderPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
|
||||
const canSubmit = !!effectiveClient && cart.length > 0;
|
||||
const loteErrors = cart.filter((it) => {
|
||||
const lote = it.loteMulVenda ?? 1;
|
||||
return lote > 1 && it.qtd % lote !== 0;
|
||||
});
|
||||
const canSubmit = !!effectiveClient && cart.length > 0 && loteErrors.length === 0;
|
||||
|
||||
// ── Handlers do carrinho ──
|
||||
const addToCart = (p: ProdutoSummary) => {
|
||||
const lote = p.loteMulVenda ?? 1;
|
||||
setCart((prev) => {
|
||||
const existing = prev.find((it) => it.idProduto === p.idErp);
|
||||
if (existing) {
|
||||
return prev.map((it) => (it.idProduto === p.idErp ? { ...it, qtd: it.qtd + 1 } : it));
|
||||
return prev.map((it) => (it.idProduto === p.idErp ? { ...it, qtd: it.qtd + lote } : it));
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
@@ -468,9 +493,10 @@ export function NewOrderPage() {
|
||||
codProduto: p.codigo,
|
||||
descProduto: p.descricao,
|
||||
unidade: p.unidade ?? '',
|
||||
qtd: 1,
|
||||
qtd: lote,
|
||||
precoUnitario: Number(p.vlPreco1),
|
||||
descontoPerc: 0,
|
||||
loteMulVenda: p.loteMulVenda,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -489,6 +515,15 @@ export function NewOrderPage() {
|
||||
mutationFn: async () => {
|
||||
if (!effectiveClient) throw new Error('Selecione um cliente para continuar.');
|
||||
if (cart.length === 0) throw new Error('Adicione ao menos um produto ao pedido.');
|
||||
const invalidos = cart.filter((it) => {
|
||||
const lote = it.loteMulVenda ?? 1;
|
||||
return lote > 1 && it.qtd % lote !== 0;
|
||||
});
|
||||
if (invalidos.length > 0) {
|
||||
throw new Error(
|
||||
`Quantidade inválida: ${invalidos.map((it) => `${it.codProduto.trim()} (lote múltiplo: ${it.loteMulVenda})`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const obsCompleta = [
|
||||
contato ? `Contato: ${contato}` : null,
|
||||
@@ -768,6 +803,21 @@ export function NewOrderPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Alerta de lote múltiplo ────────────────────────────────────── */}
|
||||
{loteErrors.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16, borderRadius: 8 }}
|
||||
message="Quantidade não respeita o lote múltiplo de venda"
|
||||
description={loteErrors
|
||||
.map(
|
||||
(it) => `${it.codProduto.trim()} — ${it.descProduto.trim()}: lote ${it.loteMulVenda}`,
|
||||
)
|
||||
.join(' · ')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Rodapé fixo ─────────────────────────────────────────────────── */}
|
||||
<OrderSummaryFooter
|
||||
total={totalPedido}
|
||||
|
||||
639
apps/web/src/cockpits/rep/OrdersErpPage.tsx
Normal file
639
apps/web/src/cockpits/rep/OrdersErpPage.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Modal,
|
||||
Dropdown,
|
||||
Grid,
|
||||
Row,
|
||||
@@ -204,17 +204,14 @@ function OrderActionsMenu({
|
||||
onView: (id: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const canDetail = order.fonte !== 'erp';
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
canDetail
|
||||
? {
|
||||
key: 'view',
|
||||
icon: <EyeOutlined />,
|
||||
label: 'Ver detalhes',
|
||||
onClick: () => onView(order.id),
|
||||
}
|
||||
: { key: 'view', icon: <EyeOutlined />, label: 'Ver detalhes', disabled: true },
|
||||
{
|
||||
key: 'view',
|
||||
icon: <EyeOutlined />,
|
||||
label: 'Ver detalhes',
|
||||
onClick: () => onView(order.id),
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
||||
@@ -227,7 +224,6 @@ function OrderActionsMenu({
|
||||
key: 'pdf',
|
||||
icon: <FilePdfOutlined />,
|
||||
label: 'Gerar PDF',
|
||||
disabled: order.fonte === 'erp',
|
||||
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }),
|
||||
},
|
||||
{
|
||||
@@ -247,9 +243,9 @@ function OrderActionsMenu({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OrderDetailDrawer ────────────────────────────────────────────────────────
|
||||
// ─── OrderDetailModal ─────────────────────────────────────────────────────────
|
||||
|
||||
function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useOrderDetail(id ?? undefined);
|
||||
|
||||
@@ -290,13 +286,16 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
<Modal
|
||||
title={data ? `Pedido ${data.numPedSar}` : 'Detalhes do Pedido'}
|
||||
open={!!id}
|
||||
onClose={onClose}
|
||||
width={520}
|
||||
placement="right"
|
||||
styles={{ body: { padding: '16px 24px' } }}
|
||||
onCancel={onClose}
|
||||
centered
|
||||
width={860}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>Fechar</Button>
|
||||
@@ -314,7 +313,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{isLoading && <Spin style={{ display: 'block', marginTop: 48, textAlign: 'center' }} />}
|
||||
{isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />}
|
||||
|
||||
{data && (
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
@@ -329,15 +328,27 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
||||
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||
>
|
||||
<Row gutter={[16, 10]}>
|
||||
<Col span={12}>
|
||||
<Col span={6}>
|
||||
<span style={label}>Pedido</span>
|
||||
<Text strong>{data.numPedSar}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col span={6}>
|
||||
<span style={label}>Data</span>
|
||||
<Text>{fmtDate(data.dtPedido)}</Text>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Col span={6}>
|
||||
<span style={label}>Total</span>
|
||||
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
||||
{fmt(data.total)}
|
||||
</Text>
|
||||
</Col>
|
||||
{data.descontoPerc && Number(data.descontoPerc) > 0 && (
|
||||
<Col span={6}>
|
||||
<span style={label}>Desconto</span>
|
||||
<Text>{Number(data.descontoPerc).toLocaleString('pt-BR')}%</Text>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={12}>
|
||||
<span style={label}>Cliente</span>
|
||||
<Text strong style={{ display: 'block' }}>
|
||||
{data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`}
|
||||
@@ -348,22 +359,10 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Col span={12}>
|
||||
<span style={label}>Representante</span>
|
||||
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<span style={label}>Total</span>
|
||||
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
||||
{fmt(data.total)}
|
||||
</Text>
|
||||
</Col>
|
||||
{data.descontoPerc && Number(data.descontoPerc) > 0 && (
|
||||
<Col span={12}>
|
||||
<span style={label}>Desconto</span>
|
||||
<Text>{Number(data.descontoPerc).toLocaleString('pt-BR')}%</Text>
|
||||
</Col>
|
||||
)}
|
||||
{data.obs && (
|
||||
<Col span={24}>
|
||||
<span style={label}>Observações</span>
|
||||
@@ -379,45 +378,62 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
||||
<Text style={{ ...label, marginBottom: 8 }}>
|
||||
Itens do Pedido ({data.itens.length})
|
||||
</Text>
|
||||
{data.itens.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
background: '#F8FAFC',
|
||||
border: '1px solid #EBF0F5',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text style={{ fontSize: 12, color: '#64748B' }}>{item.codProduto}</Text>
|
||||
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{Number(item.qtd)} un × {fmt(Number(item.precoUnitario))}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text strong className="tabular-nums">
|
||||
{fmt(Number(item.total))}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid #EBF0F5',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 15, color: '#003B8E' }}>
|
||||
Total: {fmt(data.total)}
|
||||
</Text>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={data.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 }}>{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) => <Text className="tabular-nums">{fmt(Number(v))}</Text>,
|
||||
},
|
||||
{
|
||||
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 do Pedido</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
<Text strong style={{ color: '#003B8E' }}>
|
||||
{fmt(data.total)}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -430,7 +446,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -479,7 +495,6 @@ function MobileOrderCard({
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
disabled={order.fonte === 'erp'}
|
||||
onClick={() =>
|
||||
order.situa === 0
|
||||
? void navigate({ to: '/pedidos/$id', params: { id: order.id } })
|
||||
@@ -566,20 +581,13 @@ export function OrdersPage() {
|
||||
title: 'Pedido',
|
||||
key: 'pedido',
|
||||
width: 140,
|
||||
render: (_: unknown, row: PedidoSummary) => {
|
||||
const label = row.numero ? String(row.numero) : row.numPedSar;
|
||||
return row.fonte === 'erp' ? (
|
||||
<Text strong className="tabular-nums" style={{ color: '#1F2937' }}>
|
||||
{label}
|
||||
render: (_: unknown, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||
{row.numPedSar}
|
||||
</Text>
|
||||
) : (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Cliente',
|
||||
@@ -651,7 +659,6 @@ export function OrdersPage() {
|
||||
type="primary"
|
||||
style={{ borderRadius: 6 }}
|
||||
title="Ver detalhes"
|
||||
disabled={row.fonte === 'erp'}
|
||||
onClick={() =>
|
||||
row.situa === 0
|
||||
? void navigate({ to: '/pedidos/$id', params: { id: row.id } })
|
||||
@@ -962,13 +969,12 @@ export function OrdersPage() {
|
||||
scroll={{ x: 900 }}
|
||||
onRow={(row) => ({
|
||||
onClick: () => {
|
||||
// Orçamento abre a tela grande de detalhe (com Transmitir); demais, o drawer.
|
||||
if (row.situa === 0) void navigate({ to: '/pedidos/$id', params: { id: row.id } });
|
||||
else if (row.fonte !== 'erp') setDrawerOrderId(row.id);
|
||||
else setDrawerOrderId(row.id);
|
||||
},
|
||||
style: {
|
||||
background: STATUS[row.situa]?.rowBg ?? '#fff',
|
||||
cursor: row.fonte !== 'erp' ? 'pointer' : 'default',
|
||||
cursor: 'pointer',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
})}
|
||||
@@ -997,7 +1003,7 @@ export function OrdersPage() {
|
||||
)}
|
||||
|
||||
{/* ── Drawer de detalhe ─────────────────────────────────────────── */}
|
||||
<OrderDetailDrawer id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
|
||||
<OrderDetailModal id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
|
||||
|
||||
{/* FAB mobile */}
|
||||
{isMobile && (
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faChartLine,
|
||||
faClipboardList,
|
||||
faClipboardCheck,
|
||||
faFunnelDollar,
|
||||
faCalendarDays,
|
||||
faUsers,
|
||||
@@ -52,6 +53,11 @@ export function Sidebar() {
|
||||
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||
label: 'Pedidos',
|
||||
},
|
||||
{
|
||||
key: '/pedidos-erp',
|
||||
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||
label: 'Consulta ERP',
|
||||
},
|
||||
{
|
||||
key: '/comissao',
|
||||
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
PedidoErpConsultaResponseSchema,
|
||||
PedidoListResponseSchema,
|
||||
PedidoDetailSchema,
|
||||
type PedidoErpConsultaQuery,
|
||||
type PedidoErpConsultaResponse,
|
||||
type PedidoListQuery,
|
||||
type PedidoListResponse,
|
||||
type PedidoDetail,
|
||||
@@ -55,3 +58,21 @@ export function useClientOrders(idCliente: number | undefined) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.search) search.set('search', params.search);
|
||||
if (params.from) search.set('from', params.from);
|
||||
if (params.to) search.set('to', params.to);
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.limit) search.set('limit', String(params.limit));
|
||||
|
||||
const qs = search.toString();
|
||||
return useQuery<PedidoErpConsultaResponse>({
|
||||
queryKey: ['orders-erp-consulta', params],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
|
||||
return PedidoErpConsultaResponseSchema.parse(res);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
|
||||
import { NewClientPage } from '../cockpits/rep/NewClientPage';
|
||||
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||
import { authStore } from './auth-store';
|
||||
@@ -118,6 +119,12 @@ const catalogoRoute = createRoute({
|
||||
component: CatalogPage,
|
||||
});
|
||||
|
||||
const pedidosErpRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/pedidos-erp',
|
||||
component: OrdersErpPage,
|
||||
});
|
||||
|
||||
const aprovacoes = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/aprovacoes',
|
||||
@@ -135,6 +142,7 @@ const routeTree = rootRoute.addChildren([
|
||||
pedidoDetailRoute,
|
||||
pedidoPrintRoute,
|
||||
catalogoRoute,
|
||||
pedidosErpRoute,
|
||||
aprovacoes,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user