diff --git a/apps/api/src/app/catalog/catalog.service.ts b/apps/api/src/app/catalog/catalog.service.ts index 4d076c5..878ebe7 100644 --- a/apps/api/src/app/catalog/catalog.service.ts +++ b/apps/api/src/app/catalog/catalog.service.ts @@ -283,6 +283,7 @@ export class CatalogService { ativo: Number(p.ativo), qtdEstoque: p.qtd_estoque, listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null, + loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null, }; } @@ -290,36 +291,62 @@ export class CatalogService { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); 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(` - SELECT - p.id_erp, - p.codigo, - p.descricao, - p.unidade, - p.vl_preco1::text, - p.cod_grupo, - p.grupo, - p.cod_subgrupo, - p.subgrupo, - p.marca, - p.ativo, - e.qtd_estoque::text, - p.lista_pauta, - p.referencia, - p.descr_det, - p.vl_preco2::text, - p.vl_preco3::text, - p.aliq_ipi::text, - p.peso_liquido::text, - p.qtd_volume::text, - p.lote_mul_venda, - p.preco_promocional::text - FROM vw_produtos p - 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 - LIMIT 1 - `); + interface PautaPrecoRow { + id_pauta: number; + codigo: number; + descricao: string; + preco: string; + } + + const [rows, pautaRows] = await Promise.all([ + prisma.$queryRawUnsafe(` + SELECT + p.id_erp, + p.codigo, + p.descricao, + p.unidade, + p.vl_preco1::text, + p.cod_grupo, + p.grupo, + p.cod_subgrupo, + p.subgrupo, + p.marca, + p.ativo, + e.qtd_estoque::text, + p.lista_pauta, + p.referencia, + p.descr_det, + p.vl_preco2::text, + p.vl_preco3::text, + p.aliq_ipi::text, + p.peso_liquido::text, + p.qtd_volume::text, + p.lote_mul_venda, + p.preco_promocional::text + FROM vw_produtos p + 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 + LIMIT 1 + `), + prisma.$queryRawUnsafe(` + 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]; if (!p) return null; @@ -336,6 +363,12 @@ export class CatalogService { loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null, precoComIpi: null, precoPromocional: p.preco_promocional, + pautaPrecos: pautaRows.map((r) => ({ + idPauta: Number(r.id_pauta), + codigo: Number(r.codigo), + descricao: r.descricao, + preco: r.preco, + })), }; } diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index 5d966b7..fa1344c 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -16,11 +16,14 @@ import { createZodDto } from 'nestjs-zod'; import { AprovarPedidoSchema, CreatePedidoSchema, + PedidoErpConsultaQuerySchema, PedidoListQuerySchema, RecusarPedidoSchema, type AprovarPedido, type CreatePedido, type PedidoDetail, + type PedidoErpConsultaQuery, + type PedidoErpConsultaResponse, type PedidoListQuery, type PedidoListResponse, type RecusarPedido, @@ -32,6 +35,7 @@ class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {} class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {} class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {} class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {} +class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {} @Controller({ path: 'orders' }) export class OrdersController { @@ -85,6 +89,12 @@ export class OrdersController { return this.orders.findOneErp(idPedido); } + @Get('erp-consulta') + listErpConsulta(@Query() query: PedidoErpConsultaQueryDto): Promise { + const parsed = PedidoErpConsultaQuerySchema.parse(query) as PedidoErpConsultaQuery; + return this.orders.listErpConsulta(parsed); + } + @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string): Promise { return this.orders.findOne(id); diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index d29e931..b35cca8 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -5,6 +5,9 @@ import type { AprovarPedido, CreatePedido, PedidoDetail, + PedidoErpConsultaItem, + PedidoErpConsultaQuery, + PedidoErpConsultaResponse, PedidoListQuery, PedidoListResponse, PedidoSummary, @@ -26,11 +29,6 @@ function sigToSar(sigSitua: number): number { 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 { return v ? v.toString() : '0'; } @@ -53,43 +51,7 @@ export class OrdersService { const { idCliente, situa, numPedSar, from, to, page, limit } = query; const offset = (page - 1) * limit; - // Filtro de vendedor: rep vê apenas seus pedidos - 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 = { + const where: Prisma.PedidoWhereInput = { idEmpresa, ...(role === 'rep' ? { codVendedor } : {}), ...(idCliente != null ? { idCliente } : {}), @@ -105,42 +67,13 @@ export class OrdersService { : {}), }; - const [sarPedidos, countRows] = await Promise.all([ - prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }), - prisma.$queryRawUnsafe<[{ count: string }]>(` - SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters} - `), + const [pedidos, total] = await Promise.all([ + prisma.pedido.findMany({ where, orderBy: { dtPedido: 'desc' }, skip: offset, take: limit }), + prisma.pedido.count({ where }), ]); - const sarCount = sarPedidos.length; - const erpTotal = Number(countRows[0]?.count ?? 0); - 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(` - 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 cliIds = [...new Set(pedidos.map((p) => p.idCliente))]; + const repCods = [...new Set(pedidos.map((p) => p.codVendedor))]; const [cliNameRows, repNameRows] = await Promise.all([ cliIds.length ? prisma.$queryRawUnsafe< @@ -158,7 +91,7 @@ export class OrdersService { 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 sarData: PedidoSummary[] = sarSlice.map((p) => ({ + const data: PedidoSummary[] = pedidos.map((p) => ({ id: p.id, numPedSar: p.numPedSar, idCliente: p.idCliente, @@ -175,27 +108,173 @@ export class OrdersService { fonte: 'sar' as const, })); - const erpData: PedidoSummary[] = erpRows.map((o) => ({ - id: `erp-${o.id_pedido}`, - numPedSar: (o.num_ped_sar ?? '').trim(), - numero: Number(o.numero), - idCliente: Number(o.id_cliente), - nomeCliente: o.nome_cliente ?? null, - razaoCliente: o.razao_cliente ?? null, - codVendedor: Number(o.cod_vendedor), - nomeVendedor: o.nome_vendedor ?? null, - // Normaliza situa SIG → SAR para consistência com pedidos SAR - situa: sigToSar(Number(o.situa)), - statusDescr: o.status_descr, - dtPedido: new Date(o.dt_pedido).toISOString(), - total: o.total ?? '0', - descontoPerc: o.desconto_perc ?? '0', - obs: o.obs ?? null, - createdAt: new Date(o.dt_pedido).toISOString(), - fonte: 'erp' as const, + return { data, total, page, limit }; + } + + async listErpConsulta(query: PedidoErpConsultaQuery): Promise { + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const role = this.cls.get('role'); + const userId = this.cls.get('userId'); + const codVendedor = userId ? parseInt(userId, 10) : 0; + const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; + + const { search, from, to, page, limit } = query; + const offset = (page - 1) * limit; + + const dataHistorico = new Date(); + dataHistorico.setDate(dataHistorico.getDate() - 90); + 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( + `${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 { diff --git a/apps/web/src/cockpits/rep/CatalogPage.tsx b/apps/web/src/cockpits/rep/CatalogPage.tsx index ee4e4f9..ea20da8 100644 --- a/apps/web/src/cockpits/rep/CatalogPage.tsx +++ b/apps/web/src/cockpits/rep/CatalogPage.tsx @@ -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 = [ - { - title: 'Código', - dataIndex: 'codigo', - width: 110, - render: (v: string) => {v.trim()}, - }, - { - title: 'Descrição', - dataIndex: 'descricao', - render: (v: string, row: ProdutoSummary) => ( -
-
{v.trim()}
- {row.grupo &&
{row.grupo.trim()}
} -
- ), - }, - { - title: 'Und', - dataIndex: 'unidade', - width: 60, - align: 'center', - render: (v: string | null) => v ?? '—', - }, - { - title: 'Marca', - dataIndex: 'marca', - width: 130, - render: (v: string | null) => (v ? {v.trim()} : null), - }, - { - title: 'Preço', - dataIndex: 'vlPreco1', - width: 120, - align: 'right', - render: (v: string) => ( - 0 ? '#389e0d' : '#999' }}> - {fmtPrice(v)} - - ), - }, - { - title: 'Estoque', - dataIndex: 'qtdEstoque', - width: 90, - align: 'right', - render: (v: string | null) => { - if (v == null) return '—'; - const n = Number(v); - return ( - 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')} - ); +function buildColumns(onDetail: (id: number) => void): TableColumnsType { + return [ + { + title: 'Código', + dataIndex: 'codigo', + width: 110, + render: (v: string) => {v.trim()}, }, - }, -]; + { + title: 'Descrição', + dataIndex: 'descricao', + render: (v: string, row: ProdutoSummary) => ( +
+
{v.trim()}
+ {row.grupo &&
{row.grupo.trim()}
} +
+ ), + }, + { + title: 'Und', + dataIndex: 'unidade', + width: 60, + align: 'center', + render: (v: string | null) => v ?? '—', + }, + { + title: 'Marca', + dataIndex: 'marca', + width: 130, + render: (v: string | null) => (v ? {v.trim()} : null), + }, + { + title: 'Preço', + dataIndex: 'vlPreco1', + width: 120, + align: 'right', + render: (v: string) => ( + 0 ? '#389e0d' : '#999' }}> + {fmtPrice(v)} + + ), + }, + { + title: 'Estoque', + dataIndex: 'qtdEstoque', + width: 90, + align: 'right', + render: (v: string | null) => { + if (v == null) return '—'; + const n = Number(v); + return ( + 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')} + ); + }, + }, + { + title: '', + key: 'actions', + width: 48, + align: 'center', + render: (_: unknown, row: ProdutoSummary) => ( + + + @@ -483,135 +498,141 @@ function CustomerDetailsDrawer({ } > - + {/* Identificação */} - - {summary.nome} - - {summary.razao && ( - - {summary.razao} - - )} - {summary.cgcpf && ( - - {summary.cgcpf} - - )} + + + + {summary.nome} + + {summary.razao && ( + + {summary.razao} + + )} + {summary.cgcpf && ( + + {summary.cgcpf} + + )} + + + + + - {/* Dados cadastrais */} -
- - Dados Cadastrais - - - - Telefone - - - {phone} - - - - E-mail - - - {summary.email ?? '—'} - - - {loadingDetail ? ( - - - - ) : ( - <> - {endereco !== '—' && ( - - Endereço - {endereco} - - )} - {d?.inscricaoEstadual && ( - - Insc. Estadual - {d.inscricaoEstadual} - - )} - - )} - -
- - - - {/* Dados comerciais */} -
- - Dados Comerciais - - - {limiteFormatado !== '—' && ( - - Limite de Crédito - - {limiteFormatado} - - - )} - - Representante - - {summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`} - {summary.nomeVendedor && ( - - (cód. {summary.codVendedor}) - - )} - - - {summary.dtUltimaCompra && ( - - Última Compra + {/* Dados cadastrais + comerciais em 2 colunas */} + + + + Dados Cadastrais + + +
+ Telefone - - - {fmtDate(summary.dtUltimaCompra)} - {dias !== null && ( - - ({dias} dias) - - )} + + {phone} + +
+
+ E-mail + + + + {summary.email ?? '—'} - - )} - -
+
+ {loadingDetail ? ( + + ) : ( + <> + {endereco !== '—' && ( +
+ Endereço + {endereco} +
+ )} + {d?.inscricaoEstadual && ( +
+ Insc. Estadual + {d.inscricaoEstadual} +
+ )} + + )} +
+ + + + + Dados Comerciais + + + {limiteFormatado !== '—' && ( +
+ Limite de Crédito + + {limiteFormatado} + +
+ )} +
+ Representante + + {summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`} + {summary.nomeVendedor && ( + + (cód. {summary.codVendedor}) + + )} + +
+ {summary.dtUltimaCompra && ( +
+ Última Compra + + + + {fmtDate(summary.dtUltimaCompra)} + {dias !== null && ( + + ({dias} dias) + + )} + + +
+ )} +
+ + {/* Últimos pedidos */} {(loadingOrders || orders.length > 0) && ( @@ -634,7 +655,7 @@ function CustomerDetailsDrawer({ {loadingOrders ? ( ) : ( - + {orders.slice(0, 5).map((p) => (
)} - - - + ); } -// ─── CustomerAnalysisDrawer ─────────────────────────────────────────────────── +// ─── CustomerAnalysisModal ─────────────────────────────────────────────────── -function CustomerAnalysisDrawer({ +function CustomerAnalysisModal({ summary, onClose, }: { @@ -755,25 +761,55 @@ function CustomerAnalysisDrawer({ }; return ( - - Análise Comercial + Análise Comercial — {summary.nome} + + } + open={!!summary} + onCancel={onClose} + centered + width={720} + destroyOnHidden + styles={{ body: { padding: '20px 24px' } }} + footer={ + + + + } - open - onClose={onClose} - width={480} - placement="right" - styles={{ body: { padding: '16px 24px' } }} > - +
- - {summary.nome} - - + {summary.cgcpf && ( {summary.cgcpf} @@ -875,39 +911,8 @@ function CustomerAnalysisDrawer({
)} - - - - - -
- + ); } @@ -1535,9 +1540,9 @@ export function ClientsPage() { )} - {/* ── Drawers ───────────────────────────────────────────────────── */} + {/* ── Modais ────────────────────────────────────────────────────── */} {detailSummary && ( - setDetailSummary(null)} onAnalyze={() => { @@ -1547,10 +1552,7 @@ export function ClientsPage() { /> )} {analysisSummary && ( - setAnalysisSummary(null)} - /> + setAnalysisSummary(null)} /> )} {/* FAB mobile */} diff --git a/apps/web/src/cockpits/rep/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx index d226ac5..0baf910 100644 --- a/apps/web/src/cockpits/rep/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rep/NewOrderPage.tsx @@ -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) => ( - 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 ( +
+ 1 ? `Lote: ${lote}` : undefined + } + color={invalid ? 'red' : 'blue'} + > + onQtyChange(row.key, n ?? lote)} + /> + + {lote > 1 && ( +
+ lote: {lote} +
+ )} +
+ ); + }, }, { title: 'Un.', @@ -451,14 +471,19 @@ export function NewOrderPage() { const [error, setError] = useState(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() { )} + {/* ── Alerta de lote múltiplo ────────────────────────────────────── */} + {loteErrors.length > 0 && ( + `${it.codProduto.trim()} — ${it.descProduto.trim()}: lote ${it.loteMulVenda}`, + ) + .join(' · ')} + /> + )} + {/* ── Rodapé fixo ─────────────────────────────────────────────────── */} = { + 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 ( + + {label} + + ); +} + +// ─── 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 ( + + + Pedido {row.numeroPedido} + + + {row.tipo === 'P' ? 'Pedido' : 'Entrega'} + + {row && statusTag(row)} +
+ ) : ( + 'Detalhes' + ) + } + open={!!row} + onCancel={onClose} + centered + width={860} + destroyOnHidden + styles={{ + body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' }, + }} + footer={} + > + {!row ? null : ( + + {/* ── Cabeçalho ── */} + + + + Cliente + + {row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`} + + {row.nomeCliente && row.razaoCliente && ( + + {row.nomeCliente} + + )} + + + Data + {fmtDate(row.data)} + + {row.dtPrevent && ( + + Prev. Entrega + {fmtDate(row.dtPrevent)} + + )} + + Forma Pagto. + {row.formaPagamento ?? '—'} + + + Total + + {fmt(row.total)} + + + {row.numPedSar && ( + + Nº SAR + {row.numPedSar} + + )} + {row.obs && ( + + Observações + + {row.obs} + + + )} + + + + {/* ── Dados de Entrega / NF (só tipo E) ── */} + {row.tipo === 'E' && ( + + + + Nº Entrega + + {row.numeroEntrega ?? '—'} + + + {row.dataEmissao && ( + + Data Emissão + {fmtDate(row.dataEmissao)} + + )} + {row.numeroNf && ( + + NF + + + + {row.numeroNf} + + + + )} + {row.chaveAcessoNfe && ( + + Chave NF-e + + + {row.chaveAcessoNfe} + + +