feat(web+api): cockpit rep — novo pedido v2, endereço de entrega e cancelamento de orçamento
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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<Row[]>(`
|
||||
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, '')) != ''
|
||||
|
||||
@@ -84,6 +84,11 @@ export class OrdersController {
|
||||
return this.orders.reject(id, parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/cancel')
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||
return this.orders.cancel(id);
|
||||
}
|
||||
|
||||
@Get('erp/:idPedido')
|
||||
findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise<PedidoDetail> {
|
||||
return this.orders.findOneErp(idPedido);
|
||||
|
||||
@@ -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<PedidoDetail> {
|
||||
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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -425,18 +425,23 @@ export function OrderDetailPage() {
|
||||
)}
|
||||
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||||
<Descriptions.Item label="Total">
|
||||
<Descriptions.Item label="Total" span={isOrcamento ? 1 : 2}>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{fmt(order.total)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
{order.endEntrega && (
|
||||
<Descriptions.Item label="End. Entrega" span={isOrcamento ? 3 : 2}>
|
||||
{order.endEntrega}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{order.obs && (
|
||||
<Descriptions.Item label="Observações" span={2}>
|
||||
<Descriptions.Item label="Observações" span={isOrcamento ? 3 : 2}>
|
||||
{order.obs}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{order.motivoRecusa && (
|
||||
<Descriptions.Item label="Motivo Recusa" span={2}>
|
||||
<Descriptions.Item label="Motivo Recusa" span={isOrcamento ? 3 : 2}>
|
||||
<Text type="danger">{order.motivoRecusa}</Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
@@ -399,11 +399,23 @@ export function OrderPrintPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Observações + rodapé ────────────────────────────────────────── */}
|
||||
{order.obs && (
|
||||
<div style={{ padding: '10px 28px 0' }}>
|
||||
<span style={label}>Observações</span>
|
||||
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
||||
{/* ── Endereço de entrega + Observações ───────────────────────────── */}
|
||||
{(order.endEntrega || order.obs) && (
|
||||
<div style={{ padding: '10px 28px 0', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
{order.endEntrega && (
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<span style={label}>Endereço de entrega</span>
|
||||
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
|
||||
{order.endEntrega}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{order.obs && (
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<span style={label}>Observações</span>
|
||||
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
@@ -43,6 +44,7 @@ import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
|
||||
import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
|
||||
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
@@ -250,6 +252,19 @@ function OrderActionsMenu({
|
||||
onView: (id: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { modal, message: msg } = App.useApp();
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
|
||||
onSuccess: () => {
|
||||
void msg.success('Orçamento cancelado.');
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
|
||||
});
|
||||
|
||||
const canCancel = order.situa === 0 && order.fonte !== 'erp';
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
@@ -287,8 +302,18 @@ function OrderActionsMenu({
|
||||
icon: <CloseCircleOutlined />,
|
||||
label: 'Cancelar pedido',
|
||||
danger: true,
|
||||
disabled: order.situa === 3,
|
||||
onClick: () => alert('Cancelamento em breve'),
|
||||
disabled: !canCancel,
|
||||
onClick: () => {
|
||||
if (!canCancel) return;
|
||||
void modal.confirm({
|
||||
title: 'Cancelar orçamento?',
|
||||
content: `O pedido ${order.numPedSar} será marcado como cancelado. Esta ação não pode ser desfeita.`,
|
||||
okText: 'Cancelar pedido',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: 'Voltar',
|
||||
onOk: () => cancelMutation.mutate(),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -387,7 +412,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
|
||||
{isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />}
|
||||
|
||||
{data && (
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
{/* Status */}
|
||||
<div>
|
||||
<OrderStatusBadge situa={data.situa} descr={data.statusDescr} />
|
||||
@@ -434,6 +459,12 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
|
||||
<span style={label}>Representante</span>
|
||||
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
|
||||
</Col>
|
||||
{data.endEntrega && (
|
||||
<Col span={24}>
|
||||
<span style={label}>End. Entrega</span>
|
||||
<Text type="secondary">{data.endEntrega}</Text>
|
||||
</Col>
|
||||
)}
|
||||
{data.obs && (
|
||||
<Col span={24}>
|
||||
<span style={label}>Observações</span>
|
||||
@@ -460,7 +491,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
|
||||
title: 'Produto',
|
||||
key: 'produto',
|
||||
render: (_: unknown, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
|
||||
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
|
||||
</Space>
|
||||
|
||||
@@ -115,6 +115,7 @@ export type ContatoResult = z.infer<typeof ContatoResultSchema>;
|
||||
|
||||
export const TopProdutoSchema = z.object({
|
||||
idProduto: z.number().int(),
|
||||
codProduto: z.string(),
|
||||
descricao: z.string(),
|
||||
unidade: z.string().nullable(),
|
||||
qtdTotal: z.number(),
|
||||
|
||||
@@ -88,6 +88,7 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
|
||||
comissao: z.string(),
|
||||
pedFlex: z.string(),
|
||||
formaPagamento: z.string().nullable().optional(),
|
||||
endEntrega: z.string().nullable().optional(),
|
||||
aprovadoPor: z.number().int().nullable(),
|
||||
aprovadoEm: z.iso.datetime().nullable(),
|
||||
motivoRecusa: z.string().nullable(),
|
||||
@@ -138,6 +139,7 @@ export const CreatePedidoSchema = z.object({
|
||||
idPauta: z.number().int().optional(),
|
||||
codFormapag: z.number().int().optional(),
|
||||
obs: z.string().optional(),
|
||||
endEntrega: z.string().optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
itens: z.array(CreatePedidoItemSchema).min(1),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user