From fb6df551b79247ddfbe84e83cebbd5150022ddf8 Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 29 May 2026 18:48:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20redesign=20NewOrderPage=20e=20Orde?= =?UTF-8?q?rsPage=20+=20bot=C3=A3o=20Novo=20Pedido=20global?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewOrderPage: - Layout de página única com cards (remove wizard em steps) - AutoComplete de cliente com busca na API - Badge de confirmação ao selecionar cliente - Select de Pauta (API real) e Condição de Pagamento (mock) - Campos Contato e Nº OC - AutoComplete de produto por catálogo com pauta aplicada - Soma qty automaticamente se produto já está no carrinho - Tabela de itens com qty/desconto editáveis inline - Rodapé fixo com total e botão Finalizar verde OrdersPage: - Cards de métricas (total, vendido, pendentes, aprovados, ticket médio) - Filtros por status e período (hoje / 7d / 30d) - Tabela com row-click colorido por status - Drawer lateral com detalhes, itens e timeline de histórico - Menu de ações por linha (ver, duplicar, PDF, cancelar) - Cards mobile responsivos Layout global: - Botão Novo Pedido na Topbar (sempre visível) - FAB verde fixo (bottom-right) no AppShell Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/web/src/cockpits/rep/NewOrderPage.tsx | 921 +++++++++++++------- apps/web/src/cockpits/rep/OrdersPage.tsx | 864 +++++++++++++++--- apps/web/src/components/layout/AppShell.tsx | 30 +- apps/web/src/components/layout/Topbar.tsx | 13 +- 4 files changed, 1415 insertions(+), 413 deletions(-) diff --git a/apps/web/src/cockpits/rep/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx index 9f7f2a9..ed8c015 100644 --- a/apps/web/src/cockpits/rep/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rep/NewOrderPage.tsx @@ -2,94 +2,245 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Alert, + AutoComplete, Button, - Descriptions, - Divider, - Form, + Card, + Col, + Empty, + Input, InputNumber, + Row, + Select, Space, - Spin, - Steps, Table, Tag, + Tooltip, Typography, - Input, } from 'antd'; import type { TableColumnsType } from 'antd'; -import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; -import { Link, useNavigate, useSearch } from '@tanstack/react-router'; -import type { CreatePedido, CreatePedidoItem, ProdutoSummary } from '@sar/api-interface'; -import { useClientDetail } from '../../lib/queries/clients'; -import { useCatalog } from '../../lib/queries/catalog'; +import { + ArrowLeftOutlined, + CheckCircleOutlined, + DeleteOutlined, + SearchOutlined, + ShoppingCartOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import type { ClientSummary, CreatePedido, Pauta, ProdutoSummary } from '@sar/api-interface'; +import { useClientList, useClientDetail } from '../../lib/queries/clients'; +import { useCatalog, usePautas } from '../../lib/queries/catalog'; import { apiFetch } from '../../lib/api-client'; const { Title, Text } = Typography; -const { Search } = Input; -type CartItem = CreatePedidoItem & { key: string }; +// ─── Condições de pagamento mockadas — substituir por endpoint quando disponível ── +const COND_PAGAMENTO = [ + { value: 1, label: 'À Vista' }, + { value: 2, label: 'Boleto 30 dias' }, + { value: 3, label: 'Boleto 30/60 dias' }, + { value: 4, label: 'Boleto 30/60/90 dias' }, + { value: 5, label: 'Boleto 28/56/84 dias' }, + { value: 6, label: 'PIX' }, + { value: 7, label: 'Cartão de Crédito' }, +]; -function calcItemTotal(qty: number, price: number, disc: number): number { - return Math.round(qty * price * (1 - disc / 100) * 100) / 100; -} +// ─── Tipos internos ──────────────────────────────────────────────────────────── -function fmt(n: number): string { +type CartItem = { + key: string; + idProduto: number; + codProduto: string; + descProduto: string; + unidade: string; + qtd: number; + precoUnitario: number; + descontoPerc: number; +}; + +type SearchParams = { clientId?: string }; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fmt(n: number) { return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } -// ─── Step 1 — Selecionar Produtos ──────────────────────────────────────────── +function itemTotal(item: CartItem) { + return Math.round(item.qtd * item.precoUnitario * (1 - item.descontoPerc / 100) * 100) / 100; +} -function ProductStep({ - cart, - onAdd, - onRemove, - onQtyChange, - onDiscChange, +// ─── Estilos compartilhados ─────────────────────────────────────────────────── + +const cardStyle: React.CSSProperties = { + borderRadius: 12, + boxShadow: '0 1px 6px rgba(0,0,0,0.07)', + border: '1px solid #EBF0F5', + marginBottom: 16, +}; + +const sectionLabel: React.CSSProperties = { + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.09em', + textTransform: 'uppercase', + color: '#003B8E', + marginBottom: 16, + display: 'block', +}; + +// ─── CustomerSearch ─────────────────────────────────────────────────────────── + +function CustomerSearch({ + value, + selected, + onSearch, + onSelect, }: { - cart: CartItem[]; + value: string; + selected: ClientSummary | null; + onSearch: (v: string) => void; + onSelect: (client: ClientSummary) => void; +}) { + const { data, isFetching } = useClientList({ q: value || undefined, limit: 12 }); + + const options = (data?.data ?? []).map((c) => ({ + value: String(c.idCliente), + label: ( + + + {c.razao ?? c.nome} + {c.cgcpf && ( + + {c.cgcpf} + + )} + + ), + client: c, + })); + + return ( + { + onSearch(v); + }} + onSelect={(_val, opt) => { + onSelect((opt as (typeof options)[0]).client); + }} + onChange={(v) => { + if (!v) { + onSearch(''); + } + }} + style={{ width: '100%' }} + notFoundContent={ + value.length > 1 && !isFetching ? ( + + Nenhum cliente encontrado + + ) : null + } + > + } + placeholder="Digite o nome fantasia, razão social ou CNPJ..." + allowClear + style={{ borderRadius: 8 }} + /> + + ); +} + +// ─── ProductSearch ──────────────────────────────────────────────────────────── + +function ProductSearch({ + idPauta, + onAdd, +}: { + idPauta: number | undefined; onAdd: (p: ProdutoSummary) => void; - onRemove: (key: string) => void; - onQtyChange: (key: string, qty: number) => void; - onDiscChange: (key: string, disc: number) => void; }) { const [q, setQ] = useState(''); - const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 }); + const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 15 }); - const cartKeys = new Set(cart.map((c) => String(c.idProduto))); + const options = (data?.data ?? []).map((p) => ({ + value: String(p.idErp), + label: ( + + + {p.codigo} + {p.descricao} + {p.unidade && ( + + {p.unidade} + + )} + + + {fmt(Number(p.vlPreco1))} + + + ), + produto: p, + })); - const catalogColumns: TableColumnsType = [ - { title: 'Código', dataIndex: 'codigo', width: 100 }, - { title: 'Produto', dataIndex: 'descricao', ellipsis: true }, + return ( + { + onAdd((opt as (typeof options)[0]).produto); + setQ(''); + }} + style={{ width: '100%' }} + notFoundContent={ + q.length > 1 && !isFetching ? ( + + Nenhum produto encontrado + + ) : null + } + > + } + placeholder="Pesquise por nome, código ou código de barras..." + style={{ borderRadius: 8 }} + /> + + ); +} + +// ─── OrderItemsTable ────────────────────────────────────────────────────────── + +function OrderItemsTable({ + items, + onQtyChange, + onDiscChange, + onRemove, +}: { + items: CartItem[]; + onQtyChange: (key: string, qty: number) => void; + onDiscChange: (key: string, disc: number) => void; + onRemove: (key: string) => void; +}) { + const columns: TableColumnsType = [ { - title: 'Grupo', - dataIndex: 'grupo', - width: 110, - render: (v: string | null) => (v ? {v} : null), - }, - { - title: 'Preço', - dataIndex: 'vlPreco1', - width: 110, - align: 'right', - render: (v: string) => fmt(Number(v)), - }, - { - title: '', + title: 'Cód.', + dataIndex: 'codProduto', width: 80, - render: (_: unknown, row: ProdutoSummary) => ( - - ), + render: (v: string) => {v}, + }, + { + title: 'Descrição', + dataIndex: 'descProduto', + ellipsis: true, }, - ]; - - const cartColumns: TableColumnsType = [ - { title: 'Produto', dataIndex: 'descProduto', ellipsis: true }, { title: 'Qtd', dataIndex: 'qtd', @@ -100,11 +251,28 @@ function ProductStep({ step={1} value={v} size="small" - style={{ width: 80 }} + style={{ width: 76 }} onChange={(n) => onQtyChange(row.key, n ?? 1)} /> ), }, + { + title: 'Un.', + dataIndex: 'unidade', + width: 60, + render: (v: string) => ( + + {v || '—'} + + ), + }, + { + title: 'Preço Unit.', + dataIndex: 'precoUnitario', + width: 110, + align: 'right', + render: (v: number) => {fmt(v)}, + }, { title: 'Desc %', dataIndex: 'descontoPerc', @@ -116,194 +284,228 @@ function ProductStep({ step={0.5} value={v} size="small" - style={{ width: 80 }} + style={{ width: 76 }} + addonAfter="%" onChange={(n) => onDiscChange(row.key, n ?? 0)} /> ), }, { - title: 'Subtotal', + title: 'Total', width: 120, align: 'right', - render: (_: unknown, row: CartItem) => - fmt(calcItemTotal(row.qtd, row.precoUnitario, row.descontoPerc)), + render: (_: unknown, row: CartItem) => ( + + {fmt(itemTotal(row))} + + ), }, { title: '', - width: 40, + width: 48, render: (_: unknown, row: CartItem) => ( - + + + ); } // ─── NewOrderPage ───────────────────────────────────────────────────────────── -type SearchParams = { clientId?: string }; - export function NewOrderPage() { - const { clientId } = useSearch({ strict: false }) as SearchParams; + const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams; const navigate = useNavigate(); const qc = useQueryClient(); - const clientIdNum = clientId ? Number(clientId) : undefined; - const { data: client, isLoading: clientLoading } = useClientDetail(clientIdNum); + // ── Dados do cliente ── + const [clientSearch, setClientSearch] = useState(''); + const [selectedClient, setSelectedClient] = useState(null); - const [step, setStep] = useState(0); - const [cart, setCart] = useState([]); - const [globalDisc, setGlobalDisc] = useState(0); + // Pré-carregar cliente quando vem ?clientId=X (ex.: botão "Novo Pedido" no detalhe) + const { data: preloadedClient } = useClientDetail( + clientIdParam ? Number(clientIdParam) : undefined, + ); + const effectiveClient = selectedClient ?? preloadedClient ?? null; + + // ── Campos comerciais ── + const { data: pautas = [] } = usePautas(); + const [idPauta, setIdPauta] = useState(); + const [codFormapag, setCodFormapag] = useState(); + const [contato, setContato] = useState(''); + + // ── Informações adicionais ── + const [numOC, setNumOC] = useState(''); const [obs, setObs] = useState(''); + + // ── Carrinho ── + const [cart, setCart] = useState([]); + + // ── UI ── const [error, setError] = useState(null); + const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0); + const canSubmit = !!effectiveClient && cart.length > 0; + + // ── Handlers do carrinho ── + const addToCart = (p: ProdutoSummary) => { + 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, + { + key: String(p.idErp), + idProduto: p.idErp, + codProduto: p.codigo, + descProduto: p.descricao, + unidade: p.unidade ?? '', + qtd: 1, + precoUnitario: Number(p.vlPreco1), + descontoPerc: 0, + }, + ]; + }); + }; + + const setQty = (key: string, qty: number) => + setCart((prev) => prev.map((it) => (it.key === key ? { ...it, qtd: qty } : it))); + + const setDisc = (key: string, disc: number) => + setCart((prev) => prev.map((it) => (it.key === key ? { ...it, descontoPerc: disc } : it))); + + const removeItem = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key)); + + // ── Submissão ── const mutation = useMutation({ mutationFn: async () => { - if (!clientIdNum) throw new Error('clientId ausente'); + if (!effectiveClient) throw new Error('Selecione um cliente para continuar.'); + if (cart.length === 0) throw new Error('Adicione ao menos um produto ao pedido.'); + + // Concatena campos extras em obs enquanto não há campos dedicados no backend + const obsCompleta = [ + contato ? `Contato: ${contato}` : null, + numOC ? `OC: ${numOC}` : null, + obs || null, + ] + .filter(Boolean) + .join(' | '); + const body: CreatePedido = { - idCliente: clientIdNum, - descontoPerc: globalDisc, - obs: obs || undefined, + idCliente: effectiveClient.idCliente, + idPauta, + codFormapag, + descontoPerc: 0, + obs: obsCompleta || undefined, idempotencyKey: crypto.randomUUID(), itens: cart.map((it, idx) => ({ idProduto: it.idProduto, @@ -319,112 +521,239 @@ export function NewOrderPage() { }, onSuccess: (pedido: { id: string }) => { void qc.invalidateQueries({ queryKey: ['orders'] }); - void qc.invalidateQueries({ queryKey: ['clients', clientIdNum] }); + void qc.invalidateQueries({ queryKey: ['clients'] }); void navigate({ to: '/pedidos/$id', params: { id: pedido.id } }); }, onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'), }); - const addToCart = (p: ProdutoSummary) => { - setCart((prev) => [ - ...prev, - { - key: String(p.idErp), - idProduto: p.idErp, - codProduto: p.codigo, - descProduto: p.descricao, - ordem: prev.length + 1, - qtd: 1, - precoUnitario: Number(p.vlPreco1), - descontoPerc: 0, - }, - ]); - }; - - const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key)); - const setQty = (key: string, qty: number) => - setCart((prev) => prev.map((it) => (it.key === key ? { ...it, qtd: qty } : it))); - const setDisc = (key: string, disc: number) => - setCart((prev) => prev.map((it) => (it.key === key ? { ...it, descontoPerc: disc } : it))); - - if (!clientId) - return ; - - if (clientLoading) return ; - - if (!client) - return ; - - const steps = [{ title: 'Produtos' }, { title: 'Desconto / Obs.' }, { title: 'Confirmar' }]; - - const canNext = (step === 0 && cart.length > 0) || step === 1; - return ( -
- - - ← {client.razao ?? client.nome} - - - Novo Pedido + <div style={{ maxWidth: 1100, margin: '0 auto' }}> + {/* ── Cabeçalho ─────────────────────────────────────────────────── */} + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 20, + }} + > + <Title level={3} style={{ margin: 0, color: '#003B8E' }}> + Lançamento de Pedido - - - - - {step === 0 && ( - - )} - {step === 1 && ( - - )} - {step === 2 && ( - - )} + +
{error && ( setError(null)} + style={{ marginBottom: 16, borderRadius: 8 }} /> )} - - - {step > 0 && } - {step < 2 && ( - + {/* ── Card 1: Dados do Cliente e Comercial ───────────────────────── */} + + Dados do Cliente e Comercial + + { + setSelectedClient(c); + setClientSearch(''); + }} + /> + + {effectiveClient && ( +
+ + + {effectiveClient.razao ?? effectiveClient.nome} + + {effectiveClient.cgcpf && ( + + {effectiveClient.cgcpf} + + )} + {effectiveClient.limiteCreditoStr && ( + + Limite: {fmt(Number(effectiveClient.limiteCreditoStr))} + + )} +
)} - {step === 2 && ( - + + + +
+ Pauta de Preço +
+ + + +
+ Contato Responsável +
+ setContato(e.target.value)} + placeholder="Nome de quem está comprando" + style={{ + width: '100%', + height: 32, + padding: '0 11px', + border: '1px solid #d9d9d9', + borderRadius: 6, + fontSize: 14, + outline: 'none', + boxSizing: 'border-box', + color: '#1F2937', + }} + /> + +
+
+ + {/* ── Card 2: Informações Adicionais ─────────────────────────────── */} + + Informações Adicionais + + +
+ Nº Ordem de Compra (Cliente) +
+ setNumOC(e.target.value)} + placeholder="Ex: OC-98765" + style={{ + width: '100%', + height: 32, + padding: '0 11px', + border: '1px solid #d9d9d9', + borderRadius: 6, + fontSize: 14, + outline: 'none', + boxSizing: 'border-box', + color: '#1F2937', + }} + /> + + +
+ Observações do Pedido +
+