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:
2026-06-29 14:25:24 +00:00
parent 5bda00009f
commit a6cb1df2aa
10 changed files with 1181 additions and 202 deletions

View File

@@ -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")

View File

@@ -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, '')) != ''

View File

@@ -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);

View File

@@ -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),