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:
2026-07-22 19:16:55 +00:00
parent 35fd773f14
commit 1e11de7f31
5 changed files with 361 additions and 49 deletions

View File

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

View File

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

View File

@@ -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 />}

View File

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

View File

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