From a6cb1df2aaba8d636200e2e8a5a73332509147b1 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 29 Jun 2026 14:25:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(web+api):=20cockpit=20rep=20=E2=80=94=20no?= =?UTF-8?q?vo=20pedido=20v2,=20endere=C3=A7o=20de=20entrega=20e=20cancelam?= =?UTF-8?q?ento=20de=20or=C3=A7amento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewOrderPage: etapas 1-5 — merge de cards, busca de produto melhorada (autocomplete com estoque + modal catálogo), responsividade mobile (tabela → cards, footer compacto), histórico de compras do cliente e campo de endereço de entrega (cadastrado vs livre) - endEntrega: campo TEXT adicionado em sar.pedidos; contrato CreatePedidoSchema e PedidoDetailSchema atualizados; aparece no detalhe (OrderDetailPage, OrderDetailModal) e no PDF (OrderPrintPage) - Cancelamento de orçamento: PATCH /orders/:id/cancel — rep cancela seu próprio orçamento (situa 0); histórico registrado; UI com Modal.confirm substituindo alert() em OrderActionsMenu - Fix: useClientDetail chamado para cliente selecionado manualmente, garantindo que clientEndStr seja preenchido mesmo sem clientIdParam na URL Co-Authored-By: Claude Sonnet 4.6 --- apps/api/prisma/schema.prisma | 1 + apps/api/src/app/clients/clients.service.ts | 45 +- apps/api/src/app/orders/orders.controller.ts | 5 + apps/api/src/app/orders/orders.service.ts | 85 +- apps/web/src/cockpits/rep/NewOrderPage.tsx | 1172 +++++++++++++++-- apps/web/src/cockpits/rep/OrderDetailPage.tsx | 11 +- apps/web/src/cockpits/rep/OrderPrintPage.tsx | 22 +- apps/web/src/cockpits/rep/OrdersPage.tsx | 39 +- .../api-interface/src/lib/client.contract.ts | 1 + .../api-interface/src/lib/order.contract.ts | 2 + 10 files changed, 1181 insertions(+), 202 deletions(-) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4df1da8..e78f21c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -45,6 +45,7 @@ model Pedido { comissao Decimal @default(0) @db.Decimal(15, 2) pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex") obs String? + endEntrega String? @map("end_entrega") aprovadoPor Int? @map("aprovado_por") aprovadoEm DateTime? @map("aprovado_em") motivoRecusa String? @map("motivo_recusa") diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index 5b06f88..06aae2d 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -439,6 +439,7 @@ export class ClientsService { interface Row { id_produto: number; + codigo: string; descricao: string; unidade: string | null; qtd_total: string; @@ -449,37 +450,39 @@ export class ClientsService { const rows = await prisma.$queryRawUnsafe(` SELECT - i.produ AS id_produto, + i.id_produto, + TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao, TRIM(p.unidade) AS unidade, SUM(i.qtd)::numeric(15,3)::text AS qtd_total, COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos, - MAX(ped.data) AS ultima_compra, + MAX(ped.dt_pedido) AS ultima_compra, ( - SELECT i2.pruni::numeric(15,2)::text - FROM sig.peditens i2 - JOIN sig.pedidos p2 ON p2.id_pedido = i2.id_pedido - WHERE i2.produ = i.produ - AND p2.clien = ped.clien - AND p2.id_empresa = ${idEmpresa} - AND p2.situa NOT IN (5) - ORDER BY p2.data DESC, p2.id_pedido DESC + SELECT i2.preco_unitario::numeric(15,2)::text + FROM sar.vw_peditens_erp i2 + JOIN sar.vw_pedidos_erp p2 ON p2.id_pedido = i2.id_pedido + WHERE i2.id_produto = i.id_produto + AND p2.id_cliente = ped.id_cliente + AND p2.id_empresa = ${idEmpresa} + AND p2.situa NOT IN (5) + ORDER BY p2.dt_pedido DESC, p2.id_pedido DESC LIMIT 1 ) AS ultimo_preco - FROM sig.pedidos ped - JOIN sig.peditens i ON i.id_pedido = ped.id_pedido - JOIN gestao.produto p ON p.id_erp = i.produ - AND p.id_empresa = ${idEmpresaMatriz} - WHERE ped.clien = ${idCliente} - AND ped.id_empresa = ${idEmpresa} - AND ped.situa NOT IN (5) - GROUP BY i.produ, p.descricao, p.unidade, ped.clien + FROM sar.vw_pedidos_erp ped + JOIN sar.vw_peditens_erp i ON i.id_pedido = ped.id_pedido + JOIN sar.vw_produtos p ON p.id_erp = i.id_produto + AND p.id_empresa = ${idEmpresaMatriz} + WHERE ped.id_cliente = ${idCliente} + AND ped.id_empresa = ${idEmpresa} + AND ped.situa NOT IN (5) + GROUP BY i.id_produto, p.descricao, p.unidade, ped.id_cliente ORDER BY SUM(i.qtd) DESC LIMIT ${limit} `); return rows.map((r) => ({ idProduto: Number(r.id_produto), + codProduto: r.codigo ?? '', descricao: r.descricao, unidade: r.unidade, qtdTotal: parseFloat(r.qtd_total ?? '0'), @@ -646,12 +649,12 @@ export class ClientsService { nf.dt_emissao, nf.vl_nf::text AS vl_nf, TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe - FROM gestao.nf nf - JOIN sig.pedidos p + FROM sar.vw_nf nf + JOIN sar.vw_pedidos_erp p ON p.numero = nf.num_entrega AND p.tipo = 'E' AND p.id_empresa IN (1, 9001) - WHERE p.clien = $1 + WHERE p.id_cliente = $1 AND nf.id_empresa = 1 AND nf.status = 'E' AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != '' diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index fa1344c..fae0129 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -84,6 +84,11 @@ export class OrdersController { return this.orders.reject(id, parsed); } + @Patch(':id/cancel') + cancel(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.orders.cancel(id); + } + @Get('erp/:idPedido') findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise { return this.orders.findOneErp(idPedido); diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index b35cca8..bde8585 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -128,10 +128,10 @@ export class OrdersService { 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 fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : ''; + const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : ''; + const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : ''; + const toFilterP = to ? `AND a.dt_pedido <= '${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)}%'))` @@ -164,14 +164,14 @@ export class OrdersService { 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, + COALESCE(b.dt_pedido, a.dt_pedido) AS data, + a.dt_prevent, + a.dt_emissao AS data_emissao, a.situa, NULL::text AS status_descr, - a.clien::integer AS id_cliente, + a.id_cliente, a.cod_vendedor, - TRIM(c.descr) AS forma_pagamento, + a.forma_pagamento, a.total::text, a.obs, 'E'::varchar(1) AS tipo, @@ -181,36 +181,33 @@ export class OrdersService { d.chave_acesso_nfe, b.situa AS sit_ped, a.id_pedido - FROM sig.pedidos a - LEFT JOIN sig.pedidos b + FROM sar.vw_pedidos_erp a + LEFT JOIN sar.vw_pedidos_erp 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 + LEFT JOIN sar.vw_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}' + AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${dataHistoricoStr}' ${fromFilterE} ${toFilterE} UNION ALL SELECT a.numero AS numero_pedido, - a.data, - a.dtprevent AS dt_prevent, - a.data_emissao, + a.dt_pedido AS data, + a.dt_prevent, + a.dt_emissao AS data_emissao, a.situa, c.descricao AS status_descr, - a.clien::integer AS id_cliente, + a.id_cliente, a.cod_vendedor, - TRIM(b.descr) AS forma_pagamento, + a.forma_pagamento, a.total::text, a.obs, 'P'::varchar(1) AS tipo, @@ -220,10 +217,7 @@ export class OrdersService { 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 + FROM sar.vw_pedidos_erp a LEFT JOIN vw_sitpedido c ON c.id_sitpedido = a.situa WHERE a.id_empresa = ${idEmpresa} @@ -369,6 +363,7 @@ export class OrdersService { descontoPerc: dto.descontoPerc, descontoValor: descontoValorGlobal, obs: dto.obs ?? null, + endEntrega: dto.endEntrega ?? null, idempotencyKey: dto.idempotencyKey ?? null, itens: { create: itemsData }, historico: { @@ -552,6 +547,44 @@ export class OrdersService { return this.mapDetail(final); } + async cancel(id: string): 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 userId = this.cls.get('userId') ?? '0'; + const codVendedor = parseInt(userId, 10); + + // Rep só pode cancelar seu próprio orçamento (situa 0). + const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, codVendedor } }); + if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`); + if (pedido.situa !== SITUA_ORCAMENTO) + throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante'); + + const now = new Date(); + + await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } }); + await prisma.historicoPedido.create({ + data: { + idPedido: id, + situaAnterior: SITUA_ORCAMENTO, + situaNova: SITUA_CANCELADO, + changedBy: codVendedor, + changedAt: now, + nota: 'Cancelado pelo representante', + }, + }); + + const final = await prisma.pedido.findUniqueOrThrow({ + where: { id }, + include: { + itens: { orderBy: { ordem: 'asc' } }, + historico: { orderBy: { changedAt: 'asc' } }, + }, + }); + + return this.mapDetail(final); + } + // Resolve nome do cliente (nome + razão) e nome do representante a partir dos // códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos. private async lookupNames( @@ -602,6 +635,7 @@ export class OrdersService { aprovadoEm: Date | null; motivoRecusa: string | null; obs: string | null; + endEntrega: string | null; idempotencyKey: string | null; createdAt: Date; updatedAt: Date; @@ -639,6 +673,7 @@ export class OrdersService { total: decimalToString(o.total), descontoPerc: decimalToString(o.descontoPerc), obs: o.obs, + endEntrega: o.endEntrega, createdAt: o.createdAt.toISOString(), totalProdutos: decimalToString(o.totalProdutos), totalIpi: decimalToString(o.totalIpi), diff --git a/apps/web/src/cockpits/rep/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx index 0baf910..4e1ff3b 100644 --- a/apps/web/src/cockpits/rep/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rep/NewOrderPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Alert, @@ -7,9 +7,13 @@ import { Button, Card, Col, + Divider, Empty, + Grid, Input, InputNumber, + Modal, + Radio, Row, Select, Space, @@ -20,9 +24,12 @@ import { } from 'antd'; import type { TableColumnsType } from 'antd'; import { + AppstoreOutlined, ArrowLeftOutlined, CheckCircleOutlined, + CloseOutlined, DeleteOutlined, + PlusOutlined, SearchOutlined, ShoppingCartOutlined, UserOutlined, @@ -34,13 +41,15 @@ import type { FormaPagamento, Pauta, ProdutoSummary, + TopProduto, } from '@sar/api-interface'; -import { useClientList, useClientDetail } from '../../lib/queries/clients'; +import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients'; import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog'; import { apiFetch } from '../../lib/api-client'; import { enqueueOrder } from '../../lib/offline/order-queue'; const { Title, Text } = Typography; +const { useBreakpoint } = Grid; // ─── Tipos internos ──────────────────────────────────────────────────────────── @@ -68,6 +77,21 @@ function itemTotal(item: CartItem) { return Math.round(item.qtd * item.precoUnitario * (1 - item.descontoPerc / 100) * 100) / 100; } +function stockColor(qtd: string | null): string { + if (qtd == null) return '#CBD5E1'; + const n = Number(qtd); + if (n <= 0) return '#f5222d'; + if (n < 10) return '#faad14'; + return '#52c41a'; +} + +function stockTitle(qtd: string | null): string { + if (qtd == null) return 'Estoque desconhecido'; + const n = Number(qtd); + if (n <= 0) return 'Sem estoque'; + return `Estoque: ${n.toLocaleString('pt-BR')}`; +} + // ─── Estilos compartilhados ─────────────────────────────────────────────────── const cardStyle: React.CSSProperties = { @@ -87,6 +111,13 @@ const sectionLabel: React.CSSProperties = { display: 'block', }; +const fieldLabel: React.CSSProperties = { + fontSize: 12, + fontWeight: 600, + color: '#64748B', + marginBottom: 4, +}; + // ─── CustomerSearch ─────────────────────────────────────────────────────────── function CustomerSearch({ @@ -122,16 +153,10 @@ function CustomerSearch({ { - onSearch(v); - }} - onSelect={(_val, opt) => { - onSelect((opt as (typeof options)[0]).client); - }} + onSearch={(v) => onSearch(v)} + onSelect={(_val, opt) => onSelect((opt as (typeof options)[0]).client)} onChange={(v) => { - if (!v) { - onSearch(''); - } + if (!v) onSearch(''); }} style={{ width: '100%' }} notFoundContent={ @@ -158,59 +183,757 @@ function CustomerSearch({ function ProductSearch({ idPauta, onAdd, + onOpenCatalog, }: { idPauta: number | undefined; onAdd: (p: ProdutoSummary) => void; + onOpenCatalog: () => void; }) { const [q, setQ] = useState(''); - const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 15 }); + const screens = useBreakpoint(); + const isMobile = !screens.md; + const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 25 }); const options = (data?.data ?? []).map((p) => ({ value: String(p.idErp), label: ( - - - {p.codigo} - {p.descricao} - {p.unidade && ( - - {p.unidade} - - )} +
+ + {p.codigo.trim()} +
+
+ {p.descricao.trim()} +
+ {p.grupo && ( +
+ {p.grupo.trim()} +
+ )} +
- - {fmt(Number(p.vlPreco1))} - - +
+
+ + {fmt(Number(p.vlPreco1))} + +
+
), produto: p, })); return ( - { - onAdd((opt as (typeof options)[0]).produto); - setQ(''); - }} - style={{ width: '100%' }} - notFoundContent={ - q.length > 1 && !isFetching ? ( - - Nenhum produto encontrado +
+ { + onAdd((opt as (typeof options)[0]).produto); + setQ(''); + }} + style={{ flex: 1 }} + notFoundContent={ + q.length > 1 && !isFetching ? ( + + Nenhum produto encontrado + + ) : null + } + > + } + placeholder={ + isMobile ? 'Nome ou código...' : 'Pesquise por nome, código ou código de barras...' + } + style={{ borderRadius: '8px 0 0 8px', borderRight: 'none' }} + /> + + + + +
+ ); +} + +// ─── CatalogBrowserModal ────────────────────────────────────────────────────── + +const MODAL_LIMIT = 20; + +function CatalogBrowserModal({ + open, + onClose, + idPauta, + cart, + onAdd, +}: { + open: boolean; + onClose: () => void; + idPauta: number | undefined; + cart: CartItem[]; + onAdd: (p: ProdutoSummary, qty: number) => void; +}) { + const screens = useBreakpoint(); + const isMobile = !screens.md; + + const [q, setQ] = useState(''); + const [debouncedQ, setDebouncedQ] = useState(''); + const [page, setPage] = useState(1); + const [qtys, setQtys] = useState>({}); + + useEffect(() => { + if (open) { + setQ(''); + setDebouncedQ(''); + setPage(1); + setQtys({}); + } + }, [open]); + + useEffect(() => { + const t = setTimeout(() => { + setDebouncedQ(q); + setPage(1); + }, 400); + return () => clearTimeout(t); + }, [q]); + + const { data, isLoading } = useCatalog({ + q: debouncedQ || undefined, + idPauta, + page, + limit: MODAL_LIMIT, + }); + + const cartMap = new Map(cart.map((it) => [it.idProduto, it.qtd])); + + function getQty(p: ProdutoSummary): number { + return qtys[p.idErp] ?? p.loteMulVenda ?? 1; + } + + const desktopColumns: TableColumnsType = [ + { + title: 'Código', + dataIndex: 'codigo', + width: 90, + render: (v: string) => {v.trim()}, + }, + { + title: 'Produto', + render: (_: unknown, row: ProdutoSummary) => ( +
+ {row.descricao.trim()} + {row.grupo && ( + + {row.grupo.trim()} + + )} +
+ ), + }, + { + title: 'Un.', + dataIndex: 'unidade', + width: 50, + align: 'center', + render: (v: string | null) => ( + + {v ?? '—'} + + ), + }, + { + title: 'Preço', + dataIndex: 'vlPreco1', + width: 110, + align: 'right', + render: (v: string) => ( + + {Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })} + + ), + }, + { + title: 'Estoque', + dataIndex: 'qtdEstoque', + width: 85, + align: 'right', + render: (v: string | null) => { + if (v == null) return ; + const n = Number(v); + return ( + 0 ? '#389e0d' : '#f5222d' }} className="tabular-nums"> + {n.toLocaleString('pt-BR')} - ) : null + ); + }, + }, + { + title: 'Qtd', + width: 100, + render: (_: unknown, row: ProdutoSummary) => { + const lote = row.loteMulVenda ?? 1; + const qty = getQty(row); + const invalid = lote > 1 && qty % lote !== 0; + return ( + 1 ? `Lote: ${lote}` : undefined} + color={invalid ? 'red' : 'blue'} + > + setQtys((prev) => ({ ...prev, [row.idErp]: v ?? lote }))} + /> + + ); + }, + }, + { + title: '', + width: 48, + align: 'center', + render: (_: unknown, row: ProdutoSummary) => { + const inCart = cartMap.has(row.idErp); + return ( + + +
} > - } - placeholder="Pesquise por nome, código ou código de barras..." - style={{ borderRadius: 8 }} +
+ setQ(e.target.value)} + placeholder="Buscar por código ou descrição..." + prefix={} + allowClear + style={{ flex: 1, borderRadius: 8 }} + /> + {data?.total !== undefined && ( + + {data.total.toLocaleString('pt-BR')} prod. + + )} +
+ + + rowKey="idErp" + columns={isMobile ? mobileColumns : desktopColumns} + dataSource={data?.data ?? []} + loading={isLoading} + size="small" + pagination={{ + current: page, + pageSize: MODAL_LIMIT, + total: data?.total ?? 0, + showSizeChanger: false, + onChange: (p) => setPage(p), + style: { padding: '8px 0 0' }, + }} + onRow={(row) => ({ + style: { + background: cartMap.has(row.idErp) ? '#f0f7ff' : undefined, + transition: 'background 0.15s', + }, + })} + scroll={isMobile ? { y: 320 } : { y: 380 }} /> -
+ + ); +} + +// ─── MobileCartItem ─────────────────────────────────────────────────────────── + +function MobileCartItem({ + item, + onQtyChange, + onDiscChange, + onRemove, +}: { + item: CartItem; + onQtyChange: (key: string, qty: number) => void; + onDiscChange: (key: string, disc: number) => void; + onRemove: (key: string) => void; +}) { + const lote = item.loteMulVenda ?? 1; + const invalid = lote > 1 && item.qtd % lote !== 0; + + return ( +
+ {/* Linha 1: código + nome + botão remover */} +
+
+ {item.codProduto.trim()} + + {item.descProduto.trim()} + +
+
+ + {/* Linha 2: preço unitário + un. */} +
+ + {fmt(item.precoUnitario)} / {item.unidade || 'un.'} + +
+ + {/* Linha 3: qtd + desc% + total */} +
+
+ + Qtd + + 1 ? `Lote: ${lote}` : undefined} + color={invalid ? 'red' : 'blue'} + > + onQtyChange(item.key, n ?? lote)} + /> + + {lote > 1 && ( +
+ lote: {lote} +
+ )} +
+
+ + Desc% + + onDiscChange(item.key, n ?? 0)} + /> +
+
+ + Total + + + {fmt(itemTotal(item))} + +
+
+
+ ); +} + +// ─── ClientHistoryPanel ─────────────────────────────────────────────────────── + +function ClientHistoryPanel({ + idCliente, + cart, + onAdd, +}: { + idCliente: number; + cart: CartItem[]; + onAdd: (p: TopProduto, qty: number) => void; +}) { + const screens = useBreakpoint(); + const isMobile = !screens.md; + const [collapsed, setCollapsed] = useState(false); + const [qtys, setQtys] = useState>({}); + + const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15); + + const cartSet = new Set(cart.map((it) => it.idProduto)); + + function defaultQty(p: TopProduto): number { + // Média de qtd por pedido, mínimo 1 + return Math.max(1, Math.round(p.qtdTotal / Math.max(1, p.numPedidos))); + } + + function getQty(p: TopProduto): number { + return qtys[p.idProduto] ?? defaultQty(p); + } + + if (isLoading) return null; + if (produtos.length === 0) return null; + + const desktopColumns: TableColumnsType = [ + { + title: 'Produto', + render: (_: unknown, row: TopProduto) => ( +
+
+ {row.codProduto.trim()} + {cartSet.has(row.idProduto) && ( + + )} +
+ {row.descricao} +
+ ), + }, + { + title: 'Ult. compra', + dataIndex: 'ultimaCompra', + width: 110, + render: (v: string) => ( + + {new Date(v).toLocaleDateString('pt-BR')} + + ), + }, + { + title: 'Ult. preço', + dataIndex: 'ultimoPreco', + width: 110, + align: 'right', + render: (v: number) => ( + + {fmt(v)} + + ), + }, + { + title: 'Qtd', + width: 90, + render: (_: unknown, row: TopProduto) => ( + setQtys((prev) => ({ ...prev, [row.idProduto]: v ?? 1 }))} + /> + ), + }, + { + title: '', + width: 48, + align: 'center', + render: (_: unknown, row: TopProduto) => ( + + + + + {!collapsed && ( +
+ + rowKey="idProduto" + columns={isMobile ? mobileColumns : desktopColumns} + dataSource={produtos} + size="small" + pagination={false} + showHeader={false} + onRow={(row) => ({ + style: { + background: cartSet.has(row.idProduto) ? '#f0f7ff' : undefined, + transition: 'background 0.15s', + }, + })} + /> +
+ )} + ); } @@ -227,6 +950,52 @@ function OrderItemsTable({ onDiscChange: (key: string, disc: number) => void; onRemove: (key: string) => void; }) { + const screens = useBreakpoint(); + const isMobile = !screens.md; + + // ── Mobile: cards empilhados ────────────────────────────────────── + if (isMobile) { + if (items.length === 0) { + return ( +
+ + + Nenhum produto adicionado. + + + Pesquise acima ou abra o catálogo. + +
+ ); + } + return ( +
+ {items.map((item) => ( + + ))} +
+ ); + } + + // ── Desktop: tabela ─────────────────────────────────────────────── const columns: TableColumnsType = [ { title: 'Cód.', @@ -347,10 +1116,10 @@ function OrderItemsTable({ image={} imageStyle={{ height: 60 }} description={ - + Nenhum produto adicionado ao pedido ainda. - Pesquise acima para incluir itens. + Pesquise acima ou abra o catálogo para incluir itens. } @@ -368,15 +1137,20 @@ function OrderSummaryFooter({ total, submitting, canSubmit, + missingReason, onCancel, onSubmit, }: { total: number; submitting: boolean; canSubmit: boolean; + missingReason?: string; onCancel: () => void; onSubmit: () => void; }) { + const screens = useBreakpoint(); + const isMobile = !screens.md; + return (
- +
- TOTAL DO PEDIDO + TOTAL - + <Title + level={isMobile ? 4 : 3} + style={{ margin: 0, color: '#003B8E' }} + className="tabular-nums" + > {fmt(total)} - +
- - - + + {!isMobile && ( + + )} + + +
); @@ -447,10 +1231,14 @@ export function NewOrderPage() { const [clientSearch, setClientSearch] = useState(''); const [selectedClient, setSelectedClient] = useState(null); - // Pré-carregar cliente quando vem ?clientId=X (ex.: botão "Novo Pedido" no detalhe) const { data: preloadedClient } = useClientDetail( clientIdParam ? Number(clientIdParam) : undefined, ); + // Para clientes selecionados manualmente, buscamos o Detail para ter endereco/bairro/cep. + const { data: selectedClientDetail } = useClientDetail( + !clientIdParam && selectedClient ? selectedClient.idCliente : undefined, + ); + const clientDetail = selectedClientDetail ?? preloadedClient ?? null; const effectiveClient = selectedClient ?? preloadedClient ?? null; // ── Campos comerciais ── @@ -464,11 +1252,30 @@ export function NewOrderPage() { const [numOC, setNumOC] = useState(''); const [obs, setObs] = useState(''); + // ── Endereço de entrega ── + // 'cadastrado' usa o endereço do cliente; 'outro' usa campo livre + const [endTipo, setEndTipo] = useState<'cadastrado' | 'outro'>('cadastrado'); + const [endCustom, setEndCustom] = useState(''); + // ── Carrinho ── const [cart, setCart] = useState([]); // ── UI ── const [error, setError] = useState(null); + const [catalogOpen, setCatalogOpen] = useState(false); + + // Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary. + const clientEndStr = (() => { + if (!clientDetail) return ''; + const parts = [ + clientDetail.endereco, + clientDetail.numEndereco ? `nº ${clientDetail.numEndereco}` : null, + clientDetail.bairro, + clientDetail.cep, + ].filter(Boolean); + return parts.join(', '); + })(); + const hasClientEnd = !!clientEndStr; const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0); const loteErrors = cart.filter((it) => { @@ -476,14 +1283,29 @@ export function NewOrderPage() { return lote > 1 && it.qtd % lote !== 0; }); const canSubmit = !!effectiveClient && cart.length > 0 && loteErrors.length === 0; + const missingReason = !effectiveClient + ? 'Selecione um cliente para continuar' + : cart.length === 0 + ? 'Adicione ao menos um produto ao pedido' + : loteErrors.length > 0 + ? 'Corrija as quantidades com lote múltiplo' + : undefined; + + function clearClient() { + setSelectedClient(null); + setClientSearch(''); + } // ── Handlers do carrinho ── - const addToCart = (p: ProdutoSummary) => { + const addToCart = (p: ProdutoSummary, qty?: number) => { const lote = p.loteMulVenda ?? 1; + const finalQty = qty ?? lote; 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 + lote } : it)); + return prev.map((it) => + it.idProduto === p.idErp ? { ...it, qtd: it.qtd + finalQty } : it, + ); } return [ ...prev, @@ -493,7 +1315,7 @@ export function NewOrderPage() { codProduto: p.codigo, descProduto: p.descricao, unidade: p.unidade ?? '', - qtd: lote, + qtd: finalQty, precoUnitario: Number(p.vlPreco1), descontoPerc: 0, loteMulVenda: p.loteMulVenda, @@ -502,6 +1324,29 @@ export function NewOrderPage() { }); }; + const addHistoryItemToCart = (p: TopProduto, qty: number) => { + setCart((prev) => { + const existing = prev.find((it) => it.idProduto === p.idProduto); + if (existing) { + return prev.map((it) => (it.idProduto === p.idProduto ? { ...it, qtd: it.qtd + qty } : it)); + } + return [ + ...prev, + { + key: String(p.idProduto), + idProduto: p.idProduto, + codProduto: p.codProduto, + descProduto: p.descricao, + unidade: p.unidade ?? '', + qtd: qty, + precoUnitario: p.ultimoPreco, + descontoPerc: 0, + loteMulVenda: null, + }, + ]; + }); + }; + const setQty = (key: string, qty: number) => setCart((prev) => prev.map((it) => (it.key === key ? { ...it, qtd: qty } : it))); @@ -533,12 +1378,16 @@ export function NewOrderPage() { .filter(Boolean) .join(' | '); + const endEntregaValue = + endTipo === 'cadastrado' ? clientEndStr || undefined : endCustom.trim() || undefined; + const body: CreatePedido = { idCliente: effectiveClient.idCliente, idPauta, codFormapag, descontoPerc: 0, obs: obsCompleta || undefined, + endEntrega: endEntregaValue, idempotencyKey: crypto.randomUUID(), itens: cart.map((it, idx) => ({ idProduto: it.idProduto, @@ -551,11 +1400,10 @@ export function NewOrderPage() { })), }; - // Offline: enfileira localmente e sincroniza ao reconectar (FR-4.2 / NFR-2.2) if (!navigator.onLine) { await enqueueOrder(body, effectiveClient.nome); window.dispatchEvent(new CustomEvent('sar:order-queued')); - return null; // sinaliza fluxo offline para onSuccess + return null; } return apiFetch('/orders', { method: 'POST', body }) as Promise<{ id: string }>; @@ -564,17 +1412,17 @@ export function NewOrderPage() { void qc.invalidateQueries({ queryKey: ['orders'] }); void qc.invalidateQueries({ queryKey: ['clients'] }); if (!created) { - // Offline: pedido enfileirado — mostra confirmação e fica na tela setError(null); setCart([]); setSelectedClient(null); setClientSearch(''); - setCart([]); setIdPauta(undefined); setCodFormapag(undefined); setObs(''); setContato(''); setNumOC(''); + setEndTipo('cadastrado'); + setEndCustom(''); message.success('Pedido salvo offline — será transmitido ao reconectar'); return; } @@ -621,9 +1469,9 @@ export function NewOrderPage() { /> )} - {/* ── Card 1: Dados do Cliente e Comercial ───────────────────────── */} + {/* ── Card 1: Dados do Pedido ────────────────────────────────────── */} - Dados do Cliente e Comercial + Dados do Pedido - + {effectiveClient.razao ?? effectiveClient.nome} {effectiveClient.cgcpf && ( @@ -658,18 +1507,27 @@ export function NewOrderPage() { )} {effectiveClient.limiteCreditoStr && ( - + Limite: {fmt(Number(effectiveClient.limiteCreditoStr))} )} + {!clientIdParam && ( + +