feat(web+api): catálogo com detalhe do produto, preços por pauta e consulta de pedidos ERP

- Catálogo: modal de detalhe centralizado (1100px) com identificação, estoque,
  preços base e lista de preços por pauta do representante (DISTINCT para evitar duplicatas)
- Contrato: loteMulVenda promovido para ProdutoSummarySchema; PautaPrecoSchema adicionado
- Pedido novo: valida lote múltiplo de venda — qtd inicial = lote, step = lote,
  InputNumber em erro quando inválido, alerta e bloqueio do submit
- Consulta ERP: tela OrdersErpPage com listagem de pedidos do ERP (P/E), contrato
  PedidoErpConsulta, endpoint no orders.controller, rota e sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:27:19 +00:00
parent 0858e1e941
commit a2bab75bad
15 changed files with 1804 additions and 492 deletions

View File

@@ -283,6 +283,7 @@ export class CatalogService {
ativo: Number(p.ativo), ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque, qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null, listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
}; };
} }
@@ -290,8 +291,18 @@ export class CatalogService {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(` interface PautaPrecoRow {
id_pauta: number;
codigo: number;
descricao: string;
preco: string;
}
const [rows, pautaRows] = await Promise.all([
prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT SELECT
p.id_erp, p.id_erp,
p.codigo, p.codigo,
@@ -319,7 +330,23 @@ export class CatalogService {
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa} LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.id_erp = ${idErp} AND p.ativo = 1 WHERE p.id_erp = ${idErp} AND p.ativo = 1
LIMIT 1 LIMIT 1
`); `),
prisma.$queryRawUnsafe<PautaPrecoRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao, pp.preco1::text AS preco
FROM vw_pauta_produtos pp
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
AND pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pp.id_produto = ${idErp}
AND r.codigo = ${codVendedor}
AND pp.preco1 > 0
ORDER BY pa.codigo
`),
]);
const p = rows[0]; const p = rows[0];
if (!p) return null; if (!p) return null;
@@ -336,6 +363,12 @@ export class CatalogService {
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null, loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
precoComIpi: null, precoComIpi: null,
precoPromocional: p.preco_promocional, precoPromocional: p.preco_promocional,
pautaPrecos: pautaRows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
preco: r.preco,
})),
}; };
} }

View File

@@ -16,11 +16,14 @@ import { createZodDto } from 'nestjs-zod';
import { import {
AprovarPedidoSchema, AprovarPedidoSchema,
CreatePedidoSchema, CreatePedidoSchema,
PedidoErpConsultaQuerySchema,
PedidoListQuerySchema, PedidoListQuerySchema,
RecusarPedidoSchema, RecusarPedidoSchema,
type AprovarPedido, type AprovarPedido,
type CreatePedido, type CreatePedido,
type PedidoDetail, type PedidoDetail,
type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse,
type PedidoListQuery, type PedidoListQuery,
type PedidoListResponse, type PedidoListResponse,
type RecusarPedido, type RecusarPedido,
@@ -32,6 +35,7 @@ class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {} class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {} class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {} class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' }) @Controller({ path: 'orders' })
export class OrdersController { export class OrdersController {
@@ -85,6 +89,12 @@ export class OrdersController {
return this.orders.findOneErp(idPedido); return this.orders.findOneErp(idPedido);
} }
@Get('erp-consulta')
listErpConsulta(@Query() query: PedidoErpConsultaQueryDto): Promise<PedidoErpConsultaResponse> {
const parsed = PedidoErpConsultaQuerySchema.parse(query) as PedidoErpConsultaQuery;
return this.orders.listErpConsulta(parsed);
}
@Get(':id') @Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> { findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.findOne(id); return this.orders.findOne(id);

View File

@@ -5,6 +5,9 @@ import type {
AprovarPedido, AprovarPedido,
CreatePedido, CreatePedido,
PedidoDetail, PedidoDetail,
PedidoErpConsultaItem,
PedidoErpConsultaQuery,
PedidoErpConsultaResponse,
PedidoListQuery, PedidoListQuery,
PedidoListResponse, PedidoListResponse,
PedidoSummary, PedidoSummary,
@@ -26,11 +29,6 @@ function sigToSar(sigSitua: number): number {
return sigSitua === 5 ? 3 : sigSitua; return sigSitua === 5 ? 3 : sigSitua;
} }
// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp.
function sarToSig(sarSitua: number): number {
return sarSitua === 3 ? 5 : sarSitua;
}
function decimalToString(v: Prisma.Decimal | null | undefined): string { function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0'; return v ? v.toString() : '0';
} }
@@ -53,43 +51,7 @@ export class OrdersService {
const { idCliente, situa, numPedSar, from, to, page, limit } = query; const { idCliente, situa, numPedSar, from, to, page, limit } = query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Filtro de vendedor: rep vê apenas seus pedidos const where: Prisma.PedidoWhereInput = {
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : '';
// Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp
const sigSitua = situa != null ? sarToSig(situa) : null;
const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : '';
const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : '';
const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : '';
const toFilter = to ? `AND e.dt_pedido <= '${to}'` : '';
const filters = `
WHERE e.id_empresa = ${idEmpresa}
${vendedorFilter} ${clienteFilter} ${situaFilter}
${pedSarFilter} ${fromFilter} ${toFilter}
`;
interface ErpRow {
id_pedido: number;
num_ped_sar: string;
numero: number;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
nome_vendedor: string | null;
situa: number;
status_descr: string;
dt_pedido: Date;
total: string;
desconto_perc: string;
obs: string | null;
}
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
const sarWhere: Prisma.PedidoWhereInput = {
idEmpresa, idEmpresa,
...(role === 'rep' ? { codVendedor } : {}), ...(role === 'rep' ? { codVendedor } : {}),
...(idCliente != null ? { idCliente } : {}), ...(idCliente != null ? { idCliente } : {}),
@@ -105,42 +67,13 @@ export class OrdersService {
: {}), : {}),
}; };
const [sarPedidos, countRows] = await Promise.all([ const [pedidos, total] = await Promise.all([
prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }), prisma.pedido.findMany({ where, orderBy: { dtPedido: 'desc' }, skip: offset, take: limit }),
prisma.$queryRawUnsafe<[{ count: string }]>(` prisma.pedido.count({ where }),
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
`),
]); ]);
const sarCount = sarPedidos.length; const cliIds = [...new Set(pedidos.map((p) => p.idCliente))];
const erpTotal = Number(countRows[0]?.count ?? 0); const repCods = [...new Set(pedidos.map((p) => p.codVendedor))];
const total = sarCount + erpTotal;
// Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP.
const sarSlice = sarPedidos.slice(offset, offset + limit);
const erpNeeded = limit - sarSlice.length;
const erpOffset = Math.max(0, offset - sarCount);
const erpRows =
erpNeeded > 0
? await prisma.$queryRawUnsafe<ErpRow[]>(`
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
c.nome AS nome_cliente, c.razao AS razao_cliente,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = e.cod_vendedor
LIMIT 1) AS nome_vendedor
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
${filters}
ORDER BY e.dt_pedido DESC
LIMIT ${erpNeeded} OFFSET ${erpOffset}
`)
: [];
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
const [cliNameRows, repNameRows] = await Promise.all([ const [cliNameRows, repNameRows] = await Promise.all([
cliIds.length cliIds.length
? prisma.$queryRawUnsafe< ? prisma.$queryRawUnsafe<
@@ -158,7 +91,7 @@ export class OrdersService {
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c])); const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome])); const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const sarData: PedidoSummary[] = sarSlice.map((p) => ({ const data: PedidoSummary[] = pedidos.map((p) => ({
id: p.id, id: p.id,
numPedSar: p.numPedSar, numPedSar: p.numPedSar,
idCliente: p.idCliente, idCliente: p.idCliente,
@@ -175,27 +108,173 @@ export class OrdersService {
fonte: 'sar' as const, fonte: 'sar' as const,
})); }));
const erpData: PedidoSummary[] = erpRows.map((o) => ({ return { data, total, page, limit };
id: `erp-${o.id_pedido}`, }
numPedSar: (o.num_ped_sar ?? '').trim(),
numero: Number(o.numero), async listErpConsulta(query: PedidoErpConsultaQuery): Promise<PedidoErpConsultaResponse> {
idCliente: Number(o.id_cliente), const prisma = this.cls.get('prisma');
nomeCliente: o.nome_cliente ?? null, if (!prisma) throw new Error('prisma não disponível no CLS');
razaoCliente: o.razao_cliente ?? null, const idEmpresa = this.cls.get('idEmpresa');
codVendedor: Number(o.cod_vendedor), const role = this.cls.get('role');
nomeVendedor: o.nome_vendedor ?? null, const userId = this.cls.get('userId');
// Normaliza situa SIG → SAR para consistência com pedidos SAR const codVendedor = userId ? parseInt(userId, 10) : 0;
situa: sigToSar(Number(o.situa)), const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
statusDescr: o.status_descr,
dtPedido: new Date(o.dt_pedido).toISOString(), const { search, from, to, page, limit } = query;
total: o.total ?? '0', const offset = (page - 1) * limit;
descontoPerc: o.desconto_perc ?? '0',
obs: o.obs ?? null, const dataHistorico = new Date();
createdAt: new Date(o.dt_pedido).toISOString(), dataHistorico.setDate(dataHistorico.getDate() - 90);
fonte: 'erp' as const, const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
const fromFilterE = from ? `AND COALESCE(a.data, b.data) >= '${from}'` : '';
const toFilterE = to ? `AND COALESCE(a.data, b.data) <= '${to}'` : '';
const fromFilterP = from ? `AND a.data >= '${from}'` : '';
const toFilterP = to ? `AND a.data <= '${to}'` : '';
const safe = (s: string) => s.replace(/'/g, "''");
const searchFilter = search
? `AND (r.numero_pedido::text ILIKE '%${safe(search)}%' OR LOWER(cl.nome) LIKE LOWER('%${safe(search)}%') OR LOWER(cl.razao) LIKE LOWER('%${safe(search)}%'))`
: '';
interface ErpConsultaRow {
numero_pedido: number;
data: Date;
dt_prevent: Date | null;
data_emissao: Date | null;
situa: number;
status_descr: string | null;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
forma_pagamento: string | null;
total: string;
obs: string | null;
tipo: 'P' | 'E';
num_ped_sar: string | null;
numero_entrega: number | null;
numero_nf: number | null;
chave_acesso_nfe: string | null;
sit_ped: number | null;
id_pedido: number | null;
}
const cte = `
WITH all_rows AS (
SELECT
b.numero AS numero_pedido,
COALESCE(b.data, a.data) AS data,
a.dtprevent AS dt_prevent,
a.data_emissao,
a.situa,
NULL::text AS status_descr,
a.clien::integer AS id_cliente,
a.cod_vendedor,
TRIM(c.descr) AS forma_pagamento,
a.total::text,
a.obs,
'E'::varchar(1) AS tipo,
TRIM(b.num_ped_sar) AS num_ped_sar,
a.numero AS numero_entrega,
d.numero AS numero_nf,
d.chave_acesso_nfe,
b.situa AS sit_ped,
a.id_pedido
FROM sig.pedidos a
LEFT JOIN sig.pedidos b
ON a.numer_ped_vinc = b.numero
AND a.id_empresa = b.id_empresa
AND b.tipo = 'P'
LEFT JOIN gestao.formapag c
ON c.id_empresa = ${idMatriz}
AND a.cod_formapag = c.codigo
LEFT JOIN gestao.nf d
ON a.numero = d.num_entrega
AND d.id_empresa = ${idMatriz}
WHERE a.id_empresa = ${idEmpresa}
AND a.tipo = 'E'
${vendedorFilter}
AND b.id_pedido IS NOT NULL
AND COALESCE(a.data, b.data) >= '${dataHistoricoStr}'
${fromFilterE} ${toFilterE}
UNION ALL
SELECT
a.numero AS numero_pedido,
a.data,
a.dtprevent AS dt_prevent,
a.data_emissao,
a.situa,
c.descricao AS status_descr,
a.clien::integer AS id_cliente,
a.cod_vendedor,
TRIM(b.descr) AS forma_pagamento,
a.total::text,
a.obs,
'P'::varchar(1) AS tipo,
TRIM(a.num_ped_sar) AS num_ped_sar,
NULL::integer AS numero_entrega,
NULL::integer AS numero_nf,
NULL::varchar AS chave_acesso_nfe,
NULL::integer AS sit_ped,
a.id_pedido
FROM sig.pedidos a
LEFT JOIN gestao.formapag b
ON b.id_empresa = 1
AND a.cod_formapag = b.codigo
LEFT JOIN vw_sitpedido c
ON c.id_sitpedido = a.situa
WHERE a.id_empresa = ${idEmpresa}
AND a.tipo = 'P'
${vendedorFilter}
AND a.situa NOT IN (3, 4, 5, 6)
${fromFilterP} ${toFilterP}
),
named AS (
SELECT r.*, cl.nome AS nome_cliente, cl.razao AS razao_cliente
FROM all_rows r
LEFT JOIN vw_clientes cl ON cl.id_cliente = r.id_cliente
WHERE 1=1 ${searchFilter}
)
`;
const [countRows, dataRows] = await Promise.all([
prisma.$queryRawUnsafe<[{ count: string }]>(
`${cte} SELECT COUNT(*)::text AS count FROM named`,
),
prisma.$queryRawUnsafe<ErpConsultaRow[]>(
`${cte} SELECT * FROM named ORDER BY data DESC LIMIT ${limit} OFFSET ${offset}`,
),
]);
const total = Number(countRows[0]?.count ?? 0);
const data: PedidoErpConsultaItem[] = dataRows.map((r) => ({
numeroPedido: Number(r.numero_pedido),
data: r.data ? new Date(r.data).toISOString() : new Date().toISOString(),
dtPrevent: r.dt_prevent ? new Date(r.dt_prevent).toISOString() : null,
dataEmissao: r.data_emissao ? new Date(r.data_emissao).toISOString() : null,
situa: Number(r.situa),
statusDescr: r.status_descr ?? null,
idCliente: Number(r.id_cliente),
nomeCliente: r.nome_cliente ?? null,
razaoCliente: r.razao_cliente ?? null,
codVendedor: Number(r.cod_vendedor),
formaPagamento: r.forma_pagamento ?? null,
total: r.total ?? '0',
obs: r.obs ?? null,
tipo: r.tipo as 'P' | 'E',
numPedSar: r.num_ped_sar ?? null,
numeroEntrega: r.numero_entrega != null ? Number(r.numero_entrega) : null,
numeroNf: r.numero_nf != null ? Number(r.numero_nf) : null,
chaveAcessoNfe: r.chave_acesso_nfe ?? null,
sitPed: r.sit_ped != null ? Number(r.sit_ped) : null,
idPedido: r.id_pedido != null ? Number(r.id_pedido) : null,
})); }));
return { data: [...sarData, ...erpData], total, page, limit }; return { data, total, page, limit };
} }
async findOne(id: string): Promise<PedidoDetail> { async findOne(id: string): Promise<PedidoDetail> {

View File

@@ -1,10 +1,12 @@
import { useState } from 'react'; 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 { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface'; import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog'; import { useCatalog, usePautas } from '../../lib/queries/catalog';
import { ProductDetailDrawer } from './ProductDetailDrawer';
const { Title } = Typography; const { Title, Text } = Typography;
const { Search } = Input; const { Search } = Input;
function fmtPrice(v: string | null | undefined): string { function fmtPrice(v: string | null | undefined): string {
@@ -12,7 +14,8 @@ function fmtPrice(v: string | null | undefined): string {
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—'; return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
} }
const columns: TableColumnsType<ProdutoSummary> = [ function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoSummary> {
return [
{ {
title: 'Código', title: 'Código',
dataIndex: 'codigo', dataIndex: 'codigo',
@@ -66,23 +69,50 @@ const columns: TableColumnsType<ProdutoSummary> = [
); );
}, },
}, },
{
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() { export function CatalogPage() {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [idPauta, setIdPauta] = useState<number | undefined>(); const [idPauta, setIdPauta] = useState<number | undefined>();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null);
const limit = 50; const limit = 50;
const { data: pautas, isLoading: pautasLoading } = usePautas(); const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit }); const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
const columns = buildColumns((id) => setSelectedIdErp(id));
return ( return (
<div style={{ padding: 24 }}> <div style={{ maxWidth: 1400, margin: '0 auto' }}>
<Title level={3} style={{ marginBottom: 16 }}> {/* ── Cabeçalho ────────────────────────────────────────────────── */}
<div style={{ marginBottom: 20 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Catálogo de Produtos Catálogo de Produtos
</Title> </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> <Space style={{ marginBottom: 16 }} wrap>
<Search <Search
placeholder="Buscar por código ou descrição..." placeholder="Buscar por código ou descrição..."
@@ -129,7 +159,13 @@ export function CatalogPage() {
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`, showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
onChange: (p) => setPage(p), onChange: (p) => setPage(p),
}} }}
onRow={(row) => ({
style: { cursor: 'pointer' },
onDoubleClick: () => setSelectedIdErp(row.idErp),
})}
/> />
<ProductDetailDrawer idErp={selectedIdErp} onClose={() => setSelectedIdErp(null)} />
</div> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import {
Button, Button,
Card, Card,
Col, Col,
Drawer, Modal,
Dropdown, Dropdown,
Grid, Grid,
Row, Row,
@@ -397,9 +397,9 @@ function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
); );
} }
// ─── CustomerDetailsDrawer ──────────────────────────────────────────────────── // ─── CustomerDetailsModal ────────────────────────────────────────────────────
function CustomerDetailsDrawer({ function CustomerDetailsModal({
summary, summary,
onClose, onClose,
onAnalyze, onAnalyze,
@@ -439,7 +439,7 @@ function CustomerDetailsDrawer({
}; };
return ( return (
<Drawer <Modal
title={ title={
<Space> <Space>
<Text strong style={{ fontSize: 16 }}> <Text strong style={{ fontSize: 16 }}>
@@ -448,14 +448,29 @@ function CustomerDetailsDrawer({
<CustomerStatusBadge status={summary.activityStatus} /> <CustomerStatusBadge status={summary.activityStatus} />
</Space> </Space>
} }
open open={!!summary}
onClose={onClose} onCancel={onClose}
width={520} centered
placement="right" width={860}
styles={{ body: { padding: '16px 24px' } }} destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
}}
footer={ footer={
<Space wrap> <Space wrap>
<Button onClick={onClose}>Fechar</Button> <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}> <Button icon={<BarChartOutlined />} onClick={onAnalyze}>
Analisar Analisar
</Button> </Button>
@@ -483,12 +498,14 @@ function CustomerDetailsDrawer({
</Space> </Space>
} }
> >
<Space orientation="vertical" size={20} style={{ width: '100%' }}> <Space direction="vertical" size={20} style={{ width: '100%' }}>
{/* Identificação */} {/* Identificação */}
<Card <Card
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }} style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '14px 16px' } }} styles={{ body: { padding: '14px 16px' } }}
> >
<Row gutter={[16, 8]}>
<Col span={16}>
<Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}> <Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}>
{summary.nome} {summary.nome}
</Text> </Text>
@@ -502,10 +519,16 @@ function CustomerDetailsDrawer({
{summary.cgcpf} {summary.cgcpf}
</Text> </Text>
)} )}
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
<CustomerStatusBadge status={summary.activityStatus} />
</Col>
</Row>
</Card> </Card>
{/* Dados cadastrais */} {/* Dados cadastrais + comerciais em 2 colunas */}
<div> <Row gutter={[24, 16]}>
<Col span={12}>
<Text <Text
style={{ style={{
fontSize: 12, fontSize: 12,
@@ -519,48 +542,45 @@ function CustomerDetailsDrawer({
> >
Dados Cadastrais Dados Cadastrais
</Text> </Text>
<Row gutter={[12, 12]}> <Space direction="vertical" size={10} style={{ width: '100%' }}>
<Col span={12}> <div>
<span style={label}>Telefone</span> <span style={label}>Telefone</span>
<Space size={4}> <Space size={4}>
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} /> <PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13 }}>{phone}</Text> <Text style={{ fontSize: 13 }}>{phone}</Text>
</Space> </Space>
</Col> </div>
<Col span={12}> <div>
<span style={label}>E-mail</span> <span style={label}>E-mail</span>
<Space size={4}> <Space size={4}>
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} /> <MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>{summary.email ?? '—'}</Text> <Text style={{ fontSize: 13, wordBreak: 'break-all' }}>
{summary.email ?? '—'}
</Text>
</Space> </Space>
</Col> </div>
{loadingDetail ? ( {loadingDetail ? (
<Col span={24}>
<Spin size="small" /> <Spin size="small" />
</Col>
) : ( ) : (
<> <>
{endereco !== '—' && ( {endereco !== '—' && (
<Col span={24}> <div>
<span style={label}>Endereço</span> <span style={label}>Endereço</span>
<Text style={{ fontSize: 13 }}>{endereco}</Text> <Text style={{ fontSize: 13 }}>{endereco}</Text>
</Col> </div>
)} )}
{d?.inscricaoEstadual && ( {d?.inscricaoEstadual && (
<Col span={12}> <div>
<span style={label}>Insc. Estadual</span> <span style={label}>Insc. Estadual</span>
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text> <Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
</Col> </div>
)} )}
</> </>
)} )}
</Row> </Space>
</div> </Col>
<Divider style={{ margin: '4px 0' }} /> <Col span={12}>
{/* Dados comerciais */}
<div>
<Text <Text
style={{ style={{
fontSize: 12, fontSize: 12,
@@ -574,16 +594,16 @@ function CustomerDetailsDrawer({
> >
Dados Comerciais Dados Comerciais
</Text> </Text>
<Row gutter={[12, 12]}> <Space direction="vertical" size={10} style={{ width: '100%' }}>
{limiteFormatado !== '—' && ( {limiteFormatado !== '—' && (
<Col span={12}> <div>
<span style={label}>Limite de Crédito</span> <span style={label}>Limite de Crédito</span>
<Text strong style={{ fontSize: 13, color: '#003B8E' }}> <Text strong style={{ fontSize: 13, color: '#003B8E' }}>
{limiteFormatado} {limiteFormatado}
</Text> </Text>
</Col> </div>
)} )}
<Col span={12}> <div>
<span style={label}>Representante</span> <span style={label}>Representante</span>
<Text style={{ fontSize: 13 }}> <Text style={{ fontSize: 13 }}>
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`} {summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
@@ -593,9 +613,9 @@ function CustomerDetailsDrawer({
</Text> </Text>
)} )}
</Text> </Text>
</Col> </div>
{summary.dtUltimaCompra && ( {summary.dtUltimaCompra && (
<Col span={12}> <div>
<span style={label}>Última Compra</span> <span style={label}>Última Compra</span>
<Space size={4}> <Space size={4}>
<CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} /> <CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
@@ -608,10 +628,11 @@ function CustomerDetailsDrawer({
)} )}
</Text> </Text>
</Space> </Space>
</Col>
)}
</Row>
</div> </div>
)}
</Space>
</Col>
</Row>
{/* Últimos pedidos */} {/* Últimos pedidos */}
{(loadingOrders || orders.length > 0) && ( {(loadingOrders || orders.length > 0) && (
@@ -634,7 +655,7 @@ function CustomerDetailsDrawer({
{loadingOrders ? ( {loadingOrders ? (
<Spin size="small" /> <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) => ( {orders.slice(0, 5).map((p) => (
<div <div
key={p.id} key={p.id}
@@ -706,29 +727,14 @@ function CustomerDetailsDrawer({
</div> </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> </Space>
</Drawer> </Modal>
); );
} }
// ─── CustomerAnalysisDrawer ─────────────────────────────────────────────────── // ─── CustomerAnalysisModal ───────────────────────────────────────────────────
function CustomerAnalysisDrawer({ function CustomerAnalysisModal({
summary, summary,
onClose, onClose,
}: { }: {
@@ -755,25 +761,55 @@ function CustomerAnalysisDrawer({
}; };
return ( return (
<Drawer <Modal
title={ title={
<Space> <Space>
<BarChartOutlined /> <BarChartOutlined />
<Text strong>Análise Comercial</Text> <Text strong>Análise Comercial {summary.nome}</Text>
</Space> </Space>
} }
open open={!!summary}
onClose={onClose} onCancel={onClose}
width={480} centered
placement="right" width={720}
styles={{ body: { padding: '16px 24px' } }} 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',
)
}
> >
<Space orientation="vertical" size={20} style={{ width: '100%' }}> 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 direction="vertical" size={20} style={{ width: '100%' }}>
<div> <div>
<Text strong style={{ fontSize: 17, color: '#1F2937', display: 'block' }}> <Space size={8}>
{summary.nome}
</Text>
<Space size={8} style={{ marginTop: 4 }}>
<CustomerStatusBadge status={summary.activityStatus} /> <CustomerStatusBadge status={summary.activityStatus} />
{summary.cgcpf && ( {summary.cgcpf && (
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.cgcpf}</Text> <Text style={{ fontSize: 12, color: '#64748B' }}>{summary.cgcpf}</Text>
@@ -875,39 +911,8 @@ function CustomerAnalysisDrawer({
</div> </div>
</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>
</Space> </Modal>
</Drawer>
); );
} }
@@ -1535,9 +1540,9 @@ export function ClientsPage() {
)} )}
</Row> </Row>
{/* ── Drawers ───────────────────────────────────────────────────── */} {/* ── Modais ───────────────────────────────────────────────────── */}
{detailSummary && ( {detailSummary && (
<CustomerDetailsDrawer <CustomerDetailsModal
summary={detailSummary} summary={detailSummary}
onClose={() => setDetailSummary(null)} onClose={() => setDetailSummary(null)}
onAnalyze={() => { onAnalyze={() => {
@@ -1547,10 +1552,7 @@ export function ClientsPage() {
/> />
)} )}
{analysisSummary && ( {analysisSummary && (
<CustomerAnalysisDrawer <CustomerAnalysisModal summary={analysisSummary} onClose={() => setAnalysisSummary(null)} />
summary={analysisSummary}
onClose={() => setAnalysisSummary(null)}
/>
)} )}
{/* FAB mobile */} {/* FAB mobile */}

View File

@@ -53,6 +53,7 @@ type CartItem = {
qtd: number; qtd: number;
precoUnitario: number; precoUnitario: number;
descontoPerc: number; descontoPerc: number;
loteMulVenda: number | null;
}; };
type SearchParams = { clientId?: string }; type SearchParams = { clientId?: string };
@@ -241,17 +242,36 @@ function OrderItemsTable({
{ {
title: 'Qtd', title: 'Qtd',
dataIndex: 'qtd', dataIndex: 'qtd',
width: 100, width: 120,
render: (v: number, row: CartItem) => ( 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 <InputNumber
min={0.001} min={lote}
step={1} step={lote}
value={v} value={v}
size="small" size="small"
style={{ width: 76 }} style={{ width: 84 }}
onChange={(n) => onQtyChange(row.key, n ?? 1)} 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.', title: 'Un.',
@@ -451,14 +471,19 @@ export function NewOrderPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0); 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 ── // ── Handlers do carrinho ──
const addToCart = (p: ProdutoSummary) => { const addToCart = (p: ProdutoSummary) => {
const lote = p.loteMulVenda ?? 1;
setCart((prev) => { setCart((prev) => {
const existing = prev.find((it) => it.idProduto === p.idErp); const existing = prev.find((it) => it.idProduto === p.idErp);
if (existing) { 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 [ return [
...prev, ...prev,
@@ -468,9 +493,10 @@ export function NewOrderPage() {
codProduto: p.codigo, codProduto: p.codigo,
descProduto: p.descricao, descProduto: p.descricao,
unidade: p.unidade ?? '', unidade: p.unidade ?? '',
qtd: 1, qtd: lote,
precoUnitario: Number(p.vlPreco1), precoUnitario: Number(p.vlPreco1),
descontoPerc: 0, descontoPerc: 0,
loteMulVenda: p.loteMulVenda,
}, },
]; ];
}); });
@@ -489,6 +515,15 @@ export function NewOrderPage() {
mutationFn: async () => { mutationFn: async () => {
if (!effectiveClient) throw new Error('Selecione um cliente para continuar.'); if (!effectiveClient) throw new Error('Selecione um cliente para continuar.');
if (cart.length === 0) throw new Error('Adicione ao menos um produto ao pedido.'); 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 = [ const obsCompleta = [
contato ? `Contato: ${contato}` : null, contato ? `Contato: ${contato}` : null,
@@ -768,6 +803,21 @@ export function NewOrderPage() {
)} )}
</Card> </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 ─────────────────────────────────────────────────── */} {/* ── Rodapé fixo ─────────────────────────────────────────────────── */}
<OrderSummaryFooter <OrderSummaryFooter
total={totalPedido} total={totalPedido}

View 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}> 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>
);
}

View File

@@ -5,7 +5,7 @@ import {
Card, Card,
Col, Col,
DatePicker, DatePicker,
Drawer, Modal,
Dropdown, Dropdown,
Grid, Grid,
Row, Row,
@@ -204,17 +204,14 @@ function OrderActionsMenu({
onView: (id: string) => void; onView: (id: string) => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const canDetail = order.fonte !== 'erp';
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
canDetail {
? {
key: 'view', key: 'view',
icon: <EyeOutlined />, icon: <EyeOutlined />,
label: 'Ver detalhes', label: 'Ver detalhes',
onClick: () => onView(order.id), onClick: () => onView(order.id),
} },
: { key: 'view', icon: <EyeOutlined />, label: 'Ver detalhes', disabled: true },
{ {
key: 'duplicate', key: 'duplicate',
icon: <CopyOutlined style={{ color: '#0057D9' }} />, icon: <CopyOutlined style={{ color: '#0057D9' }} />,
@@ -227,7 +224,6 @@ function OrderActionsMenu({
key: 'pdf', key: 'pdf',
icon: <FilePdfOutlined />, icon: <FilePdfOutlined />,
label: 'Gerar PDF', label: 'Gerar PDF',
disabled: order.fonte === 'erp',
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }), 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 navigate = useNavigate();
const { data, isLoading } = useOrderDetail(id ?? undefined); const { data, isLoading } = useOrderDetail(id ?? undefined);
@@ -290,13 +286,16 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
}; };
return ( return (
<Drawer <Modal
title={data ? `Pedido ${data.numPedSar}` : 'Detalhes do Pedido'} title={data ? `Pedido ${data.numPedSar}` : 'Detalhes do Pedido'}
open={!!id} open={!!id}
onClose={onClose} onCancel={onClose}
width={520} centered
placement="right" width={860}
styles={{ body: { padding: '16px 24px' } }} destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
}}
footer={ footer={
<Space> <Space>
<Button onClick={onClose}>Fechar</Button> <Button onClick={onClose}>Fechar</Button>
@@ -314,7 +313,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
</Space> </Space>
} }
> >
{isLoading && <Spin style={{ display: 'block', marginTop: 48, textAlign: 'center' }} />} {isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />}
{data && ( {data && (
<Space direction="vertical" size={20} style={{ width: '100%' }}> <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' }} style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
> >
<Row gutter={[16, 10]}> <Row gutter={[16, 10]}>
<Col span={12}> <Col span={6}>
<span style={label}>Pedido</span> <span style={label}>Pedido</span>
<Text strong>{data.numPedSar}</Text> <Text strong>{data.numPedSar}</Text>
</Col> </Col>
<Col span={12}> <Col span={6}>
<span style={label}>Data</span> <span style={label}>Data</span>
<Text>{fmtDate(data.dtPedido)}</Text> <Text>{fmtDate(data.dtPedido)}</Text>
</Col> </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> <span style={label}>Cliente</span>
<Text strong style={{ display: 'block' }}> <Text strong style={{ display: 'block' }}>
{data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`} {data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`}
@@ -348,22 +359,10 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
</Text> </Text>
)} )}
</Col> </Col>
<Col span={24}> <Col span={12}>
<span style={label}>Representante</span> <span style={label}>Representante</span>
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text> <Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
</Col> </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 && ( {data.obs && (
<Col span={24}> <Col span={24}>
<span style={label}>Observações</span> <span style={label}>Observações</span>
@@ -379,45 +378,62 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
<Text style={{ ...label, marginBottom: 8 }}> <Text style={{ ...label, marginBottom: 8 }}>
Itens do Pedido ({data.itens.length}) Itens do Pedido ({data.itens.length})
</Text> </Text>
{data.itens.map((item) => ( <Table
<div rowKey="id"
key={item.id} dataSource={data.itens}
style={{ size="small"
display: 'flex', pagination={false}
justifyContent: 'space-between', style={{ borderRadius: 8, border: '1px solid #EBF0F5' }}
alignItems: 'center', columns={[
padding: '8px 12px', {
borderRadius: 8, title: 'Produto',
background: '#F8FAFC', key: 'produto',
border: '1px solid #EBF0F5', render: (_: unknown, item) => (
marginBottom: 6,
}}
>
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Text style={{ fontSize: 12, color: '#64748B' }}>{item.codProduto}</Text> <Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text> <Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{Number(item.qtd)} un × {fmt(Number(item.precoUnitario))}
</Text>
</Space> </Space>
<Text strong className="tabular-nums"> ),
{fmt(Number(item.total))} },
{
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> </Text>
</div> ),
))} },
<div ]}
style={{ summary={() => (
display: 'flex', <Table.Summary.Row>
justifyContent: 'flex-end', <Table.Summary.Cell index={0} colSpan={3} align="right">
marginTop: 8, <Text strong>Total do Pedido</Text>
paddingTop: 8, </Table.Summary.Cell>
borderTop: '1px solid #EBF0F5', <Table.Summary.Cell index={1} align="right">
}} <Text strong style={{ color: '#003B8E' }}>
> {fmt(data.total)}
<Text strong style={{ fontSize: 15, color: '#003B8E' }}>
Total: {fmt(data.total)}
</Text> </Text>
</div> </Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
</div> </div>
)} )}
@@ -430,7 +446,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
)} )}
</Space> </Space>
)} )}
</Drawer> </Modal>
); );
} }
@@ -479,7 +495,6 @@ function MobileOrderCard({
<Button <Button
size="small" size="small"
icon={<EyeOutlined />} icon={<EyeOutlined />}
disabled={order.fonte === 'erp'}
onClick={() => onClick={() =>
order.situa === 0 order.situa === 0
? void navigate({ to: '/pedidos/$id', params: { id: order.id } }) ? void navigate({ to: '/pedidos/$id', params: { id: order.id } })
@@ -566,20 +581,13 @@ export function OrdersPage() {
title: 'Pedido', title: 'Pedido',
key: 'pedido', key: 'pedido',
width: 140, width: 140,
render: (_: unknown, row: PedidoSummary) => { 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}
</Text>
) : (
<Link to="/pedidos/$id" params={{ id: row.id }}> <Link to="/pedidos/$id" params={{ id: row.id }}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}> <Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{label} {row.numPedSar}
</Text> </Text>
</Link> </Link>
); ),
},
}, },
{ {
title: 'Cliente', title: 'Cliente',
@@ -651,7 +659,6 @@ export function OrdersPage() {
type="primary" type="primary"
style={{ borderRadius: 6 }} style={{ borderRadius: 6 }}
title="Ver detalhes" title="Ver detalhes"
disabled={row.fonte === 'erp'}
onClick={() => onClick={() =>
row.situa === 0 row.situa === 0
? void navigate({ to: '/pedidos/$id', params: { id: row.id } }) ? void navigate({ to: '/pedidos/$id', params: { id: row.id } })
@@ -962,13 +969,12 @@ export function OrdersPage() {
scroll={{ x: 900 }} scroll={{ x: 900 }}
onRow={(row) => ({ onRow={(row) => ({
onClick: () => { 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 } }); 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: { style: {
background: STATUS[row.situa]?.rowBg ?? '#fff', background: STATUS[row.situa]?.rowBg ?? '#fff',
cursor: row.fonte !== 'erp' ? 'pointer' : 'default', cursor: 'pointer',
verticalAlign: 'top', verticalAlign: 'top',
}, },
})} })}
@@ -997,7 +1003,7 @@ export function OrdersPage() {
)} )}
{/* ── Drawer de detalhe ─────────────────────────────────────────── */} {/* ── Drawer de detalhe ─────────────────────────────────────────── */}
<OrderDetailDrawer id={drawerOrderId} onClose={() => setDrawerOrderId(null)} /> <OrderDetailModal id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
{/* FAB mobile */} {/* FAB mobile */}
{isMobile && ( {isMobile && (

View 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 &amp; 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>
);
}

View File

@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faChartLine, faChartLine,
faClipboardList, faClipboardList,
faClipboardCheck,
faFunnelDollar, faFunnelDollar,
faCalendarDays, faCalendarDays,
faUsers, faUsers,
@@ -52,6 +53,11 @@ export function Sidebar() {
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />, icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos', label: 'Pedidos',
}, },
{
key: '/pedidos-erp',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP',
},
{ {
key: '/comissao', key: '/comissao',
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />, icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,

View File

@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
PedidoErpConsultaResponseSchema,
PedidoListResponseSchema, PedidoListResponseSchema,
PedidoDetailSchema, PedidoDetailSchema,
type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse,
type PedidoListQuery, type PedidoListQuery,
type PedidoListResponse, type PedidoListResponse,
type PedidoDetail, 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);
},
});
}

View File

@@ -16,6 +16,7 @@ import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
import { NewClientPage } from '../cockpits/rep/NewClientPage'; import { NewClientPage } from '../cockpits/rep/NewClientPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage'; import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { authStore } from './auth-store'; import { authStore } from './auth-store';
@@ -118,6 +119,12 @@ const catalogoRoute = createRoute({
component: CatalogPage, component: CatalogPage,
}); });
const pedidosErpRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos-erp',
component: OrdersErpPage,
});
const aprovacoes = createRoute({ const aprovacoes = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/aprovacoes', path: '/aprovacoes',
@@ -135,6 +142,7 @@ const routeTree = rootRoute.addChildren([
pedidoDetailRoute, pedidoDetailRoute,
pedidoPrintRoute, pedidoPrintRoute,
catalogoRoute, catalogoRoute,
pedidosErpRoute,
aprovacoes, aprovacoes,
]); ]);

View File

@@ -153,3 +153,46 @@ export const RecusarPedidoSchema = z.object({
motivo: z.string().min(1), motivo: z.string().min(1),
}); });
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>; export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
// ─── ERP Consulta ─────────────────────────────────────────────────────────────
export const PedidoErpConsultaItemSchema = z.object({
numeroPedido: z.number().int(),
data: z.string(),
dtPrevent: z.string().nullable(),
dataEmissao: z.string().nullable(),
situa: z.number().int(),
statusDescr: z.string().nullable(),
idCliente: z.number().int(),
nomeCliente: z.string().nullable(),
razaoCliente: z.string().nullable(),
codVendedor: z.number().int(),
formaPagamento: z.string().nullable(),
total: z.string(),
obs: z.string().nullable(),
tipo: z.enum(['P', 'E']),
numPedSar: z.string().nullable(),
numeroEntrega: z.number().int().nullable(),
numeroNf: z.number().int().nullable(),
chaveAcessoNfe: z.string().nullable(),
sitPed: z.number().int().nullable(),
idPedido: z.number().int().nullable(),
});
export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
export const PedidoErpConsultaQuerySchema = z.object({
search: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type PedidoErpConsultaQuery = z.infer<typeof PedidoErpConsultaQuerySchema>;
export const PedidoErpConsultaResponseSchema = z.object({
data: z.array(PedidoErpConsultaItemSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
limit: z.number().int().positive(),
});
export type PedidoErpConsultaResponse = z.infer<typeof PedidoErpConsultaResponseSchema>;

View File

@@ -20,6 +20,7 @@ export const ProdutoSummarySchema = z.object({
ativo: z.number().int(), ativo: z.number().int(),
qtdEstoque: z.string().nullable(), qtdEstoque: z.string().nullable(),
listaParauta: z.number().int().nullable(), listaParauta: z.number().int().nullable(),
loteMulVenda: z.number().int().nullable(),
}); });
export const PautaSchema = z.object({ export const PautaSchema = z.object({
@@ -42,6 +43,14 @@ export type ProdutoSummary = z.infer<typeof ProdutoSummarySchema>;
// ─── Produto Detail ─────────────────────────────────────────────────────────── // ─── Produto Detail ───────────────────────────────────────────────────────────
export const PautaPrecoSchema = z.object({
idPauta: z.number().int(),
codigo: z.number().int(),
descricao: z.string(),
preco: z.string(),
});
export type PautaPreco = z.infer<typeof PautaPrecoSchema>;
export const ProdutoDetailSchema = ProdutoSummarySchema.extend({ export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
referencia: z.string().nullable(), referencia: z.string().nullable(),
descricaoDetalhada: z.string().nullable(), descricaoDetalhada: z.string().nullable(),
@@ -50,9 +59,9 @@ export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
aliqIpi: z.string().nullable(), aliqIpi: z.string().nullable(),
pesoLiquido: z.string().nullable(), pesoLiquido: z.string().nullable(),
qtdVolume: z.string().nullable(), qtdVolume: z.string().nullable(),
loteMulVenda: z.number().int().nullable(),
precoComIpi: z.string().nullable(), precoComIpi: z.string().nullable(),
precoPromocional: z.string().nullable(), precoPromocional: z.string().nullable(),
pautaPrecos: z.array(PautaPrecoSchema),
}); });
export type ProdutoDetail = z.infer<typeof ProdutoDetailSchema>; export type ProdutoDetail = z.infer<typeof ProdutoDetailSchema>;

View File

@@ -112,3 +112,13 @@ LEFT JOIN gestao.pauta pau
AND pau.id_empresa = CASE WHEN p.id_empresa > 9000 AND pau.id_empresa = CASE WHEN p.id_empresa > 9000
THEN p.id_empresa - 9000 THEN p.id_empresa - 9000
ELSE p.id_empresa END; ELSE p.id_empresa END;
-- ---------------------------------------------------------------------------
-- 3. STATUS DE PEDIDO ERP
-- Fonte: sig.sitpedido
-- ---------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_sitpedido AS
SELECT
s.id_sitpedido,
TRIM(s.descricao) AS descricao
FROM sig.sitpedido s;