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:
@@ -44,6 +44,7 @@ import type {
|
||||
TopProduto,
|
||||
} from '@sar/api-interface';
|
||||
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
||||
import { useOrderDetail } from '../../lib/queries/orders';
|
||||
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
||||
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
|
||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||
@@ -71,7 +72,7 @@ type CartItem = {
|
||||
codSubgrupo: number | null;
|
||||
};
|
||||
|
||||
type SearchParams = { clientId?: string };
|
||||
type SearchParams = { clientId?: string; editar?: string };
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1291,11 +1292,16 @@ function OrderSummaryFooter({
|
||||
// ─── 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 qc = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
|
||||
// ── Modo edição: orçamento (situa 0) carregado para alteração ──
|
||||
const { data: editOrder } = useOrderDetail(editarId);
|
||||
|
||||
// ── Dados do cliente ──
|
||||
const [clientSearch, setClientSearch] = useState('');
|
||||
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
|
||||
@@ -1307,8 +1313,11 @@ export function NewOrderPage() {
|
||||
const { data: selectedClientDetail } = useClientDetail(
|
||||
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
|
||||
);
|
||||
const clientDetail = selectedClientDetail ?? preloadedClient ?? null;
|
||||
const effectiveClient = selectedClient ?? preloadedClient ?? null;
|
||||
const { data: editClient } = useClientDetail(
|
||||
editarId && editOrder ? editOrder.idCliente : undefined,
|
||||
);
|
||||
const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null;
|
||||
const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null;
|
||||
|
||||
// ── Campos comerciais ──
|
||||
const { data: pautas = [] } = usePautas();
|
||||
@@ -1333,6 +1342,39 @@ export function NewOrderPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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.
|
||||
const clientEndStr = (() => {
|
||||
if (!clientDetail) return '';
|
||||
@@ -1483,7 +1525,7 @@ export function NewOrderPage() {
|
||||
descontoPerc: 0,
|
||||
obs: obsCompleta || undefined,
|
||||
endEntrega: endEntregaValue,
|
||||
idempotencyKey: randomUUID(),
|
||||
idempotencyKey: editarId ? undefined : randomUUID(),
|
||||
itens: cart.map((it, idx) => ({
|
||||
idProduto: it.idProduto,
|
||||
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) {
|
||||
await enqueueOrder(body, effectiveClient.nome);
|
||||
window.dispatchEvent(new CustomEvent('sar:order-queued'));
|
||||
@@ -1506,6 +1558,11 @@ export function NewOrderPage() {
|
||||
onSuccess: (created) => {
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
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) {
|
||||
setError(null);
|
||||
setCart([]);
|
||||
@@ -1523,7 +1580,7 @@ export function NewOrderPage() {
|
||||
}
|
||||
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 (
|
||||
@@ -1538,7 +1595,9 @@ export function NewOrderPage() {
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||
Lançamento de Pedido
|
||||
{editarId
|
||||
? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}`
|
||||
: 'Lançamento de Pedido'}
|
||||
</Title>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Dropdown,
|
||||
Grid,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
DollarOutlined,
|
||||
EditOutlined,
|
||||
EllipsisOutlined,
|
||||
EyeOutlined,
|
||||
FilePdfOutlined,
|
||||
@@ -244,6 +247,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
|
||||
|
||||
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
|
||||
|
||||
const MOTIVOS_CANCELAMENTO = [
|
||||
'Cliente desistiu',
|
||||
'Preço',
|
||||
'Prazo de entrega',
|
||||
'Erro de lançamento',
|
||||
'Outro',
|
||||
];
|
||||
|
||||
function OrderActionsMenu({
|
||||
order,
|
||||
onView,
|
||||
@@ -253,18 +264,24 @@ function OrderActionsMenu({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
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({
|
||||
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
|
||||
mutationFn: (body: { motivo: string; descricao?: string }) =>
|
||||
apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH', body }),
|
||||
onSuccess: () => {
|
||||
void msg.success('Orçamento cancelado.');
|
||||
setCancelOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
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 canEdit = canCancel;
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
@@ -273,6 +290,16 @@ function OrderActionsMenu({
|
||||
label: 'Ver detalhes',
|
||||
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',
|
||||
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
||||
@@ -305,22 +332,57 @@ function OrderActionsMenu({
|
||||
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(),
|
||||
});
|
||||
cancelForm.resetFields();
|
||||
setCancelOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
||||
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
||||
</Dropdown>
|
||||
<>
|
||||
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
||||
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user