feat(api,web): edicao de orcamento e cancelamento com motivo
- PATCH /orders/:id edita orcamento (situa 0): substitui itens, recalcula totais, valida alcada do dono do pedido; depois de transmitido nao e mais editavel (400) - Menu "Editar orcamento" abre o lancamento pre-carregado (/pedidos/novo?editar=id) com cliente, itens, pauta, forma de pagamento, obs e endereco; salvar faz PATCH em vez de POST - Detalhe do pedido passa a incluir idPauta/codFormapag e codGrupo/codSubgrupo por item (teto de desconto na edicao) - Cancelar pedido abre card com motivo (select) + descricao livre; gravado em motivo_recusa e no historico (ASCII: banco ERP e LATIN1, em-dash quebrava o UPDATE) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,16 @@ import { ClsService } from 'nestjs-cls';
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
AprovarPedidoSchema,
|
AprovarPedidoSchema,
|
||||||
|
CancelPedidoSchema,
|
||||||
CreatePedidoSchema,
|
CreatePedidoSchema,
|
||||||
|
UpdatePedidoSchema,
|
||||||
PedidoErpConsultaQuerySchema,
|
PedidoErpConsultaQuerySchema,
|
||||||
PedidoListQuerySchema,
|
PedidoListQuerySchema,
|
||||||
RecusarPedidoSchema,
|
RecusarPedidoSchema,
|
||||||
type AprovarPedido,
|
type AprovarPedido,
|
||||||
|
type CancelPedido,
|
||||||
type CreatePedido,
|
type CreatePedido,
|
||||||
|
type UpdatePedido,
|
||||||
type PedidoDetail,
|
type PedidoDetail,
|
||||||
type PedidoErpConsultaQuery,
|
type PedidoErpConsultaQuery,
|
||||||
type PedidoErpConsultaResponse,
|
type PedidoErpConsultaResponse,
|
||||||
@@ -33,6 +37,8 @@ import { OrdersService } from './orders.service';
|
|||||||
|
|
||||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||||
|
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
|
||||||
|
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
|
||||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||||
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||||
@@ -85,8 +91,22 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/cancel')
|
@Patch(':id/cancel')
|
||||||
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
cancel(
|
||||||
return this.orders.cancel(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() body: CancelPedidoDto,
|
||||||
|
): Promise<PedidoDetail> {
|
||||||
|
const parsed = CancelPedidoSchema.parse(body) as CancelPedido;
|
||||||
|
return this.orders.cancel(id, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edição de orçamento (situa 0) — depois de transmitido não é mais editável
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() body: UpdatePedidoDto,
|
||||||
|
): Promise<PedidoDetail> {
|
||||||
|
const parsed = UpdatePedidoSchema.parse(body) as UpdatePedido;
|
||||||
|
return this.orders.update(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('erp/:idPedido')
|
@Get('erp/:idPedido')
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { ClsService } from 'nestjs-cls';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
AprovarPedido,
|
AprovarPedido,
|
||||||
|
CancelPedido,
|
||||||
CreatePedido,
|
CreatePedido,
|
||||||
|
UpdatePedido,
|
||||||
PedidoDetail,
|
PedidoDetail,
|
||||||
PedidoErpConsultaItem,
|
PedidoErpConsultaItem,
|
||||||
PedidoErpConsultaQuery,
|
PedidoErpConsultaQuery,
|
||||||
@@ -330,6 +332,52 @@ export class OrdersService {
|
|||||||
return this.mapDetail(o);
|
return this.mapDetail(o);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valida a alçada de um payload de criação/edição (mesma regra do transmit):
|
||||||
|
// desconto global e por item contra alcada + promoção/regra do dono do pedido.
|
||||||
|
private async assertAlcadaDto(
|
||||||
|
codVendedor: number,
|
||||||
|
dto: {
|
||||||
|
descontoPerc: number;
|
||||||
|
idPauta?: number;
|
||||||
|
itens: {
|
||||||
|
ordem: number;
|
||||||
|
idProduto: number;
|
||||||
|
codProduto?: string;
|
||||||
|
precoUnitario: number;
|
||||||
|
descontoPerc: number;
|
||||||
|
}[];
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
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 limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||||
|
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||||
|
const limiteDefault = limitMap.get(0) ?? 5;
|
||||||
|
if (dto.descontoPerc > limiteDefault) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para continuar.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertAlcadaItens(
|
||||||
|
{
|
||||||
|
codVendedor,
|
||||||
|
descontoPerc: dto.descontoPerc,
|
||||||
|
idPauta: dto.idPauta ?? null,
|
||||||
|
itens: dto.itens.map((it) => ({
|
||||||
|
ordem: it.ordem,
|
||||||
|
idProduto: it.idProduto,
|
||||||
|
codProduto: it.codProduto ?? null,
|
||||||
|
precoUnitario: it.precoUnitario,
|
||||||
|
descontoPerc: it.descontoPerc,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
limitMap,
|
||||||
|
limiteDefault,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
|
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
|
||||||
// JÁ AQUI (bloqueia o Concluir com a mensagem do que passou) e de novo no
|
// JÁ AQUI (bloqueia o Concluir com a mensagem do que passou) e de novo no
|
||||||
// transmit() — defesa em profundidade, pois aprovação/ERP podem mudar valores.
|
// transmit() — defesa em profundidade, pois aprovação/ERP podem mudar valores.
|
||||||
@@ -368,30 +416,7 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
|
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
|
||||||
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
await this.assertAlcadaDto(codVendedor, dto);
|
||||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
|
||||||
const limiteDefault = limitMap.get(0) ?? 5;
|
|
||||||
if (dto.descontoPerc > limiteDefault) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para concluir o pedido.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.assertAlcadaItens(
|
|
||||||
{
|
|
||||||
codVendedor,
|
|
||||||
descontoPerc: dto.descontoPerc,
|
|
||||||
idPauta: dto.idPauta ?? null,
|
|
||||||
itens: dto.itens.map((it) => ({
|
|
||||||
ordem: it.ordem,
|
|
||||||
idProduto: it.idProduto,
|
|
||||||
codProduto: it.codProduto ?? null,
|
|
||||||
precoUnitario: it.precoUnitario,
|
|
||||||
descontoPerc: it.descontoPerc,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
limitMap,
|
|
||||||
limiteDefault,
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemsData = dto.itens.map((it) => {
|
const itemsData = dto.itens.map((it) => {
|
||||||
const descontoValor =
|
const descontoValor =
|
||||||
@@ -774,7 +799,98 @@ export class OrdersService {
|
|||||||
return this.mapDetail(final);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(id: string): Promise<PedidoDetail> {
|
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
|
||||||
|
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
|
||||||
|
async update(id: string, dto: UpdatePedido): 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 role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
const repFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? { codVendedor }
|
||||||
|
: role === 'supervisor'
|
||||||
|
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
|
||||||
|
: {};
|
||||||
|
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||||
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
|
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
|
||||||
|
);
|
||||||
|
|
||||||
|
// PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
|
||||||
|
if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
|
||||||
|
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
dto.idCliente,
|
||||||
|
);
|
||||||
|
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||||
|
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alçada do DONO do pedido (supervisor editando usa o limite do rep)
|
||||||
|
await this.assertAlcadaDto(pedido.codVendedor, dto);
|
||||||
|
|
||||||
|
const itemsData = dto.itens.map((it) => {
|
||||||
|
const descontoValor =
|
||||||
|
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||||
|
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
|
||||||
|
return {
|
||||||
|
ordem: it.ordem,
|
||||||
|
idProduto: it.idProduto,
|
||||||
|
codProduto: it.codProduto ?? null,
|
||||||
|
descProduto: it.descProduto,
|
||||||
|
qtd: it.qtd,
|
||||||
|
precoUnitario: it.precoUnitario,
|
||||||
|
descontoPerc: it.descontoPerc,
|
||||||
|
descontoValor,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
|
||||||
|
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||||
|
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
|
||||||
|
|
||||||
|
const final = await prisma.pedido.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
idCliente: dto.idCliente,
|
||||||
|
idPauta: dto.idPauta ?? null,
|
||||||
|
codFormapag: dto.codFormapag ?? null,
|
||||||
|
totalProdutos,
|
||||||
|
total,
|
||||||
|
descontoPerc: dto.descontoPerc,
|
||||||
|
descontoValor: descontoValorGlobal,
|
||||||
|
obs: dto.obs ?? null,
|
||||||
|
endEntrega: dto.endEntrega ?? null,
|
||||||
|
itens: { deleteMany: {}, create: itemsData },
|
||||||
|
historico: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
situaAnterior: SITUA_ORCAMENTO,
|
||||||
|
situaNova: SITUA_ORCAMENTO,
|
||||||
|
changedBy: codVendedor,
|
||||||
|
changedAt: new Date(),
|
||||||
|
nota: 'Orçamento editado',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapDetail(final);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, dto: CancelPedido): Promise<PedidoDetail> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
@@ -788,8 +904,15 @@ export class OrdersService {
|
|||||||
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
|
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// ATENCAO: banco ERP e LATIN1 - sem em-dash/caracteres fora do LATIN1 aqui
|
||||||
|
const motivoCompleto = dto.descricao?.trim()
|
||||||
|
? `${dto.motivo} - ${dto.descricao.trim()}`
|
||||||
|
: dto.motivo;
|
||||||
|
|
||||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
|
await prisma.pedido.update({
|
||||||
|
where: { id },
|
||||||
|
data: { situa: SITUA_CANCELADO, motivoRecusa: motivoCompleto },
|
||||||
|
});
|
||||||
await prisma.historicoPedido.create({
|
await prisma.historicoPedido.create({
|
||||||
data: {
|
data: {
|
||||||
idPedido: id,
|
idPedido: id,
|
||||||
@@ -797,7 +920,7 @@ export class OrdersService {
|
|||||||
situaNova: SITUA_CANCELADO,
|
situaNova: SITUA_CANCELADO,
|
||||||
changedBy: codVendedor,
|
changedBy: codVendedor,
|
||||||
changedAt: now,
|
changedAt: now,
|
||||||
nota: 'Cancelado pelo representante',
|
nota: `Cancelado pelo representante - ${motivoCompleto}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -849,6 +972,8 @@ export class OrdersService {
|
|||||||
codVendedor: number;
|
codVendedor: number;
|
||||||
situa: number;
|
situa: number;
|
||||||
dtPedido: Date;
|
dtPedido: Date;
|
||||||
|
idPauta: number | null;
|
||||||
|
codFormapag: number | null;
|
||||||
total: Prisma.Decimal;
|
total: Prisma.Decimal;
|
||||||
descontoPerc: Prisma.Decimal;
|
descontoPerc: Prisma.Decimal;
|
||||||
descontoValor: Prisma.Decimal;
|
descontoValor: Prisma.Decimal;
|
||||||
@@ -887,6 +1012,24 @@ export class OrdersService {
|
|||||||
}[];
|
}[];
|
||||||
}): Promise<PedidoDetail> {
|
}): Promise<PedidoDetail> {
|
||||||
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
||||||
|
|
||||||
|
// Grupo/subgrupo dos produtos (vw_produtos, empresa matriz) — a edição do
|
||||||
|
// orçamento usa para calcular o teto de desconto por item no carrinho.
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
const prodIds = [...new Set(o.itens.map((i) => i.idProduto))];
|
||||||
|
const grupoRows =
|
||||||
|
prisma && prodIds.length > 0
|
||||||
|
? await prisma.$queryRawUnsafe<
|
||||||
|
{ id_erp: number; cod_grupo: number | null; cod_subgrupo: number | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT id_erp, cod_grupo, cod_subgrupo FROM vw_produtos
|
||||||
|
WHERE id_empresa = ${idMatriz} AND id_erp IN (${prodIds.join(',')})`,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const grupoMap = new Map(grupoRows.map((g) => [Number(g.id_erp), g]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: o.id,
|
id: o.id,
|
||||||
numPedSar: o.numPedSar,
|
numPedSar: o.numPedSar,
|
||||||
@@ -897,6 +1040,8 @@ export class OrdersService {
|
|||||||
nomeVendedor: names.nomeVendedor,
|
nomeVendedor: names.nomeVendedor,
|
||||||
situa: o.situa,
|
situa: o.situa,
|
||||||
dtPedido: o.dtPedido.toISOString(),
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
|
idPauta: o.idPauta,
|
||||||
|
codFormapag: o.codFormapag,
|
||||||
total: decimalToString(o.total),
|
total: decimalToString(o.total),
|
||||||
descontoPerc: decimalToString(o.descontoPerc),
|
descontoPerc: decimalToString(o.descontoPerc),
|
||||||
obs: o.obs,
|
obs: o.obs,
|
||||||
@@ -925,6 +1070,14 @@ export class OrdersService {
|
|||||||
precoUnitario: decimalToString(it.precoUnitario),
|
precoUnitario: decimalToString(it.precoUnitario),
|
||||||
descontoPerc: decimalToString(it.descontoPerc),
|
descontoPerc: decimalToString(it.descontoPerc),
|
||||||
total: decimalToString(it.total),
|
total: decimalToString(it.total),
|
||||||
|
codGrupo:
|
||||||
|
grupoMap.get(it.idProduto)?.cod_grupo != null
|
||||||
|
? Number(grupoMap.get(it.idProduto)?.cod_grupo)
|
||||||
|
: null,
|
||||||
|
codSubgrupo:
|
||||||
|
grupoMap.get(it.idProduto)?.cod_subgrupo != null
|
||||||
|
? Number(grupoMap.get(it.idProduto)?.cod_subgrupo)
|
||||||
|
: null,
|
||||||
})),
|
})),
|
||||||
historico: o.historico.map((h) => ({
|
historico: o.historico.map((h) => ({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type {
|
|||||||
TopProduto,
|
TopProduto,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
||||||
|
import { useOrderDetail } from '../../lib/queries/orders';
|
||||||
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
||||||
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
|
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
|
||||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
@@ -71,7 +72,7 @@ type CartItem = {
|
|||||||
codSubgrupo: number | null;
|
codSubgrupo: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SearchParams = { clientId?: string };
|
type SearchParams = { clientId?: string; editar?: string };
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1291,11 +1292,16 @@ function OrderSummaryFooter({
|
|||||||
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
|
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function NewOrderPage() {
|
export function NewOrderPage() {
|
||||||
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
|
const { clientId: clientIdParam, editar: editarId } = useSearch({
|
||||||
|
strict: false,
|
||||||
|
}) as SearchParams;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
// ── Modo edição: orçamento (situa 0) carregado para alteração ──
|
||||||
|
const { data: editOrder } = useOrderDetail(editarId);
|
||||||
|
|
||||||
// ── Dados do cliente ──
|
// ── Dados do cliente ──
|
||||||
const [clientSearch, setClientSearch] = useState('');
|
const [clientSearch, setClientSearch] = useState('');
|
||||||
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
|
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
|
||||||
@@ -1307,8 +1313,11 @@ export function NewOrderPage() {
|
|||||||
const { data: selectedClientDetail } = useClientDetail(
|
const { data: selectedClientDetail } = useClientDetail(
|
||||||
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
|
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
|
||||||
);
|
);
|
||||||
const clientDetail = selectedClientDetail ?? preloadedClient ?? null;
|
const { data: editClient } = useClientDetail(
|
||||||
const effectiveClient = selectedClient ?? preloadedClient ?? null;
|
editarId && editOrder ? editOrder.idCliente : undefined,
|
||||||
|
);
|
||||||
|
const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null;
|
||||||
|
const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null;
|
||||||
|
|
||||||
// ── Campos comerciais ──
|
// ── Campos comerciais ──
|
||||||
const { data: pautas = [] } = usePautas();
|
const { data: pautas = [] } = usePautas();
|
||||||
@@ -1333,6 +1342,39 @@ export function NewOrderPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Pré-carrega o formulário quando entra em modo edição (uma vez só)
|
||||||
|
const [editLoaded, setEditLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editarId || !editOrder || editLoaded) return;
|
||||||
|
setEditLoaded(true);
|
||||||
|
if (editOrder.situa !== 0) {
|
||||||
|
setError('Este pedido já foi transmitido e não pode mais ser editado.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCart(
|
||||||
|
editOrder.itens.map((it) => ({
|
||||||
|
key: String(it.idProduto),
|
||||||
|
idProduto: it.idProduto,
|
||||||
|
codProduto: it.codProduto ?? '',
|
||||||
|
descProduto: it.descProduto ?? '',
|
||||||
|
unidade: '',
|
||||||
|
qtd: Number(it.qtd),
|
||||||
|
precoUnitario: Number(it.precoUnitario),
|
||||||
|
descontoPerc: Number(it.descontoPerc),
|
||||||
|
loteMulVenda: null,
|
||||||
|
codGrupo: it.codGrupo ?? null,
|
||||||
|
codSubgrupo: it.codSubgrupo ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setIdPauta(editOrder.idPauta ?? undefined);
|
||||||
|
setCodFormapag(editOrder.codFormapag ?? undefined);
|
||||||
|
setObs(editOrder.obs ?? '');
|
||||||
|
if (editOrder.endEntrega) {
|
||||||
|
setEndTipo('outro');
|
||||||
|
setEndCustom(editOrder.endEntrega);
|
||||||
|
}
|
||||||
|
}, [editarId, editOrder, editLoaded]);
|
||||||
|
|
||||||
// Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
|
// Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
|
||||||
const clientEndStr = (() => {
|
const clientEndStr = (() => {
|
||||||
if (!clientDetail) return '';
|
if (!clientDetail) return '';
|
||||||
@@ -1483,7 +1525,7 @@ export function NewOrderPage() {
|
|||||||
descontoPerc: 0,
|
descontoPerc: 0,
|
||||||
obs: obsCompleta || undefined,
|
obs: obsCompleta || undefined,
|
||||||
endEntrega: endEntregaValue,
|
endEntrega: endEntregaValue,
|
||||||
idempotencyKey: randomUUID(),
|
idempotencyKey: editarId ? undefined : randomUUID(),
|
||||||
itens: cart.map((it, idx) => ({
|
itens: cart.map((it, idx) => ({
|
||||||
idProduto: it.idProduto,
|
idProduto: it.idProduto,
|
||||||
codProduto: it.codProduto,
|
codProduto: it.codProduto,
|
||||||
@@ -1495,6 +1537,16 @@ export function NewOrderPage() {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Edição altera o orçamento existente — requer conexão
|
||||||
|
if (editarId) {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
throw new Error('Edição de orçamento requer conexão. Tente novamente online.');
|
||||||
|
}
|
||||||
|
return apiFetch(`/orders/${editarId}`, { method: 'PATCH', body }) as Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
await enqueueOrder(body, effectiveClient.nome);
|
await enqueueOrder(body, effectiveClient.nome);
|
||||||
window.dispatchEvent(new CustomEvent('sar:order-queued'));
|
window.dispatchEvent(new CustomEvent('sar:order-queued'));
|
||||||
@@ -1506,6 +1558,11 @@ export function NewOrderPage() {
|
|||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['clients'] });
|
void qc.invalidateQueries({ queryKey: ['clients'] });
|
||||||
|
if (editarId && created) {
|
||||||
|
message.success('Orçamento atualizado');
|
||||||
|
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!created) {
|
if (!created) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setCart([]);
|
setCart([]);
|
||||||
@@ -1523,7 +1580,7 @@ export function NewOrderPage() {
|
|||||||
}
|
}
|
||||||
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
|
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
|
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao salvar pedido'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1538,7 +1595,9 @@ export function NewOrderPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
Lançamento de Pedido
|
{editarId
|
||||||
|
? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}`
|
||||||
|
: 'Lançamento de Pedido'}
|
||||||
</Title>
|
</Title>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
|
EditOutlined,
|
||||||
EllipsisOutlined,
|
EllipsisOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
FilePdfOutlined,
|
FilePdfOutlined,
|
||||||
@@ -244,6 +247,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
|
|||||||
|
|
||||||
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
|
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOTIVOS_CANCELAMENTO = [
|
||||||
|
'Cliente desistiu',
|
||||||
|
'Preço',
|
||||||
|
'Prazo de entrega',
|
||||||
|
'Erro de lançamento',
|
||||||
|
'Outro',
|
||||||
|
];
|
||||||
|
|
||||||
function OrderActionsMenu({
|
function OrderActionsMenu({
|
||||||
order,
|
order,
|
||||||
onView,
|
onView,
|
||||||
@@ -253,18 +264,24 @@ function OrderActionsMenu({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { modal, message: msg } = App.useApp();
|
const { message: msg } = App.useApp();
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [cancelForm] = Form.useForm<{ motivo: string; descricao?: string }>();
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
|
mutationFn: (body: { motivo: string; descricao?: string }) =>
|
||||||
|
apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH', body }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void msg.success('Orçamento cancelado.');
|
void msg.success('Orçamento cancelado.');
|
||||||
|
setCancelOpen(false);
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
|
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Orçamento (situa 0) pode ser editado e cancelado; transmitido, não.
|
||||||
const canCancel = order.situa === 0 && order.fonte !== 'erp';
|
const canCancel = order.situa === 0 && order.fonte !== 'erp';
|
||||||
|
const canEdit = canCancel;
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
const items: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
@@ -273,6 +290,16 @@ function OrderActionsMenu({
|
|||||||
label: 'Ver detalhes',
|
label: 'Ver detalhes',
|
||||||
onClick: () => onView(order.id),
|
onClick: () => onView(order.id),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
label: 'Editar orçamento',
|
||||||
|
disabled: !canEdit,
|
||||||
|
onClick: () => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
void navigate({ to: '/pedidos/novo', search: { editar: order.id } });
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'duplicate',
|
key: 'duplicate',
|
||||||
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
||||||
@@ -305,22 +332,57 @@ function OrderActionsMenu({
|
|||||||
disabled: !canCancel,
|
disabled: !canCancel,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!canCancel) return;
|
if (!canCancel) return;
|
||||||
void modal.confirm({
|
cancelForm.resetFields();
|
||||||
title: 'Cancelar orçamento?',
|
setCancelOpen(true);
|
||||||
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(),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
||||||
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`Cancelar orçamento ${order.numPedSar}?`}
|
||||||
|
open={cancelOpen}
|
||||||
|
onCancel={() => setCancelOpen(false)}
|
||||||
|
okText="Cancelar pedido"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
cancelText="Voltar"
|
||||||
|
confirmLoading={cancelMutation.isPending}
|
||||||
|
onOk={() =>
|
||||||
|
void cancelForm.validateFields().then((values) => cancelMutation.mutate(values))
|
||||||
|
}
|
||||||
|
centered
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
O pedido será marcado como cancelado. Esta ação não pode ser desfeita.
|
||||||
|
</Typography.Text>
|
||||||
|
<Form form={cancelForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="motivo"
|
||||||
|
label="Motivo do cancelamento"
|
||||||
|
rules={[{ required: true, message: 'Informe o motivo' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Selecione o motivo"
|
||||||
|
options={MOTIVOS_CANCELAMENTO.map((m) => ({ value: m, label: m }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="descricao" label="Descrição (opcional)">
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
placeholder="Detalhe o que aconteceu, se quiser"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export const PedidoItemSchema = z.object({
|
|||||||
precoUnitario: z.string(),
|
precoUnitario: z.string(),
|
||||||
descontoPerc: z.string(),
|
descontoPerc: z.string(),
|
||||||
total: z.string(),
|
total: z.string(),
|
||||||
|
// Grupo/subgrupo do produto (vw_produtos) — edição do orçamento usa para o
|
||||||
|
// teto de desconto. Ausente em pedidos ERP.
|
||||||
|
codGrupo: z.number().int().nullable().optional(),
|
||||||
|
codSubgrupo: z.number().int().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
|
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
|
||||||
|
|
||||||
@@ -89,6 +93,9 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
|
|||||||
pedFlex: z.string(),
|
pedFlex: z.string(),
|
||||||
formaPagamento: z.string().nullable().optional(),
|
formaPagamento: z.string().nullable().optional(),
|
||||||
endEntrega: z.string().nullable().optional(),
|
endEntrega: z.string().nullable().optional(),
|
||||||
|
// Pauta/forma de pagamento do pedido SAR — edição do orçamento pré-carrega
|
||||||
|
idPauta: z.number().int().nullable().optional(),
|
||||||
|
codFormapag: z.number().int().nullable().optional(),
|
||||||
aprovadoPor: z.number().int().nullable(),
|
aprovadoPor: z.number().int().nullable(),
|
||||||
aprovadoEm: z.iso.datetime().nullable(),
|
aprovadoEm: z.iso.datetime().nullable(),
|
||||||
motivoRecusa: z.string().nullable(),
|
motivoRecusa: z.string().nullable(),
|
||||||
@@ -149,6 +156,17 @@ export const CreatePedidoSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
|
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
|
||||||
|
|
||||||
|
// Edição de orçamento (situa 0): mesmo shape da criação, sem idempotencyKey
|
||||||
|
export const UpdatePedidoSchema = CreatePedidoSchema.omit({ idempotencyKey: true });
|
||||||
|
export type UpdatePedido = z.infer<typeof UpdatePedidoSchema>;
|
||||||
|
|
||||||
|
// Cancelamento com motivo obrigatório + descrição livre opcional
|
||||||
|
export const CancelPedidoSchema = z.object({
|
||||||
|
motivo: z.string().min(1).max(60),
|
||||||
|
descricao: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
|
||||||
|
|
||||||
export const AprovarPedidoSchema = z.object({
|
export const AprovarPedidoSchema = z.object({
|
||||||
descontoPerc: z.number().min(0).max(100).optional(),
|
descontoPerc: z.number().min(0).max(100).optional(),
|
||||||
nota: z.string().optional(),
|
nota: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user