- Endpoint GET /orders/erp/:idPedido para pedidos do histórico ERP
(endpoint estático antes de /:id com ParseUUIDPipe, sem conflito)
- JOIN vw_peditens_erp + vw_produtos: itens com codigo + descricao do produto
- forma_pagamento direto da vw_pedidos_erp (ex: "28/35/42 DIAS")
- Retorna PedidoDetail completo: totais, ipi, icmsst, comissao, obs
- Frontend: useOrderDetail detecta 'erp-*' → chama /orders/erp/{id}
- OrderDetailPage: Cond. Pagamento nas Descriptions; oculta botões
Transmitir/Aprovar/Recusar para pedidos ERP (read-only)
- PedidoItemSchema.id relaxado de uuid() para string() (ERP usa '{id}-{ordem}')
- PedidoDetailSchema: campo formaPagamento opcional adicionado
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
import { useState } from 'react';
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Button,
|
||
Descriptions,
|
||
Divider,
|
||
Form,
|
||
InputNumber,
|
||
Modal,
|
||
Space,
|
||
Spin,
|
||
Table,
|
||
Tag,
|
||
Timeline,
|
||
Typography,
|
||
Input,
|
||
message,
|
||
} from 'antd';
|
||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||
import { FilePdfOutlined } from '@ant-design/icons';
|
||
import type { TableColumnsType } from 'antd';
|
||
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
||
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||
import { SITUA_LABEL } from '@sar/api-interface';
|
||
import { useOrderDetail } from '../../lib/queries/orders';
|
||
import { useClientOrders } from '../../lib/queries/orders';
|
||
import { apiFetch } from '../../lib/api-client';
|
||
import { authStore } from '../../lib/auth-store';
|
||
|
||
const { Title, Text } = Typography;
|
||
const { TextArea } = Input;
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
const SITUA_COLOR: Record<number, string> = {
|
||
0: 'default',
|
||
1: 'warning',
|
||
2: 'processing',
|
||
3: 'error',
|
||
4: 'success',
|
||
};
|
||
|
||
function fmt(v: string | number): string {
|
||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||
}
|
||
|
||
function buildShareText(order: {
|
||
numPedSar: string;
|
||
idCliente: number;
|
||
total: string;
|
||
itens: Array<{ descProduto: string | null; qtd: string; precoUnitario: string }>;
|
||
}): string {
|
||
const lines = [
|
||
`*Pedido ${order.numPedSar} — Cliente ${order.idCliente}*`,
|
||
'',
|
||
...order.itens.map(
|
||
(it) =>
|
||
`• ${it.descProduto ?? '?'} × ${Number(it.qtd).toLocaleString('pt-BR')} — ${fmt(it.precoUnitario)} un.`,
|
||
),
|
||
'',
|
||
`*Total: ${fmt(order.total)}*`,
|
||
];
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function getRoleFromToken(): string {
|
||
const token = authStore.get();
|
||
if (!token) return 'rep';
|
||
try {
|
||
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
|
||
return (payload.role as string) ?? 'rep';
|
||
} catch {
|
||
return 'rep';
|
||
}
|
||
}
|
||
|
||
// ─── Subcomponents ────────────────────────────────────────────────────────────
|
||
|
||
const itemColumns: TableColumnsType<PedidoItem> = [
|
||
{ title: 'Código', dataIndex: 'codProduto', width: 100 },
|
||
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
|
||
{ title: 'Qtd', dataIndex: 'qtd', width: 90, align: 'right' },
|
||
{
|
||
title: 'Preço Unit.',
|
||
dataIndex: 'precoUnitario',
|
||
width: 120,
|
||
align: 'right',
|
||
render: (v: string) => fmt(v),
|
||
},
|
||
{
|
||
title: 'Desc %',
|
||
dataIndex: 'descontoPerc',
|
||
width: 80,
|
||
align: 'right',
|
||
render: (v: string) => `${v}%`,
|
||
},
|
||
{
|
||
title: 'Total',
|
||
dataIndex: 'total',
|
||
width: 130,
|
||
align: 'right',
|
||
render: (v: string) => fmt(v),
|
||
},
|
||
];
|
||
|
||
function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
||
return (
|
||
<Timeline
|
||
items={history.map((h) => ({
|
||
color:
|
||
SITUA_COLOR[h.situaNova] === 'success'
|
||
? 'green'
|
||
: SITUA_COLOR[h.situaNova] === 'warning'
|
||
? 'orange'
|
||
: SITUA_COLOR[h.situaNova] === 'error'
|
||
? 'red'
|
||
: 'blue',
|
||
children: (
|
||
<div>
|
||
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
||
{h.situaAnterior != null && (
|
||
<Text type="secondary">
|
||
{' '}
|
||
(de {SITUA_LABEL[h.situaAnterior] ?? String(h.situaAnterior)})
|
||
</Text>
|
||
)}
|
||
<br />
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{new Date(h.changedAt).toLocaleString('pt-BR')} — cod. {h.changedBy}
|
||
</Text>
|
||
{h.nota && (
|
||
<div style={{ marginTop: 4 }}>
|
||
<Text italic>"{h.nota}"</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
),
|
||
}))}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ─── Approve Modal ────────────────────────────────────────────────────────────
|
||
|
||
function ApproveModal({
|
||
open,
|
||
originalDiscount,
|
||
onConfirm,
|
||
onCancel,
|
||
loading,
|
||
}: {
|
||
open: boolean;
|
||
originalDiscount: string;
|
||
onConfirm: (descontoPerc?: number, nota?: string) => void;
|
||
onCancel: () => void;
|
||
loading: boolean;
|
||
}) {
|
||
const [disc, setDisc] = useState<number | null>(null);
|
||
const [nota, setNota] = useState('');
|
||
|
||
return (
|
||
<Modal
|
||
title="Aprovar Pedido"
|
||
open={open}
|
||
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
|
||
onCancel={onCancel}
|
||
okText="Confirmar Aprovação"
|
||
cancelText="Voltar"
|
||
confirmLoading={loading}
|
||
>
|
||
<Form layout="vertical">
|
||
<Form.Item
|
||
label={`Desconto global (original: ${originalDiscount}%)`}
|
||
help="Deixe em branco para manter o desconto solicitado."
|
||
>
|
||
<InputNumber
|
||
min={0}
|
||
max={100}
|
||
step={0.5}
|
||
placeholder={originalDiscount}
|
||
value={disc}
|
||
onChange={(v) => setDisc(v)}
|
||
addonAfter="%"
|
||
style={{ width: 160 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item label="Observação (opcional)">
|
||
<TextArea
|
||
rows={2}
|
||
value={nota}
|
||
onChange={(e) => setNota(e.target.value)}
|
||
maxLength={300}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ─── Reject Modal ─────────────────────────────────────────────────────────────
|
||
|
||
function RejectModal({
|
||
open,
|
||
onConfirm,
|
||
onCancel,
|
||
loading,
|
||
}: {
|
||
open: boolean;
|
||
onConfirm: (motivo: string) => void;
|
||
onCancel: () => void;
|
||
loading: boolean;
|
||
}) {
|
||
const [motivo, setMotivo] = useState('');
|
||
|
||
return (
|
||
<Modal
|
||
title="Recusar Pedido"
|
||
open={open}
|
||
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
|
||
onCancel={onCancel}
|
||
okText="Confirmar Recusa"
|
||
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
|
||
cancelText="Voltar"
|
||
confirmLoading={loading}
|
||
>
|
||
<Form layout="vertical">
|
||
<Form.Item label="Motivo da recusa" required>
|
||
<TextArea
|
||
rows={3}
|
||
value={motivo}
|
||
onChange={(e) => setMotivo(e.target.value)}
|
||
maxLength={500}
|
||
showCount
|
||
placeholder="Informe o motivo para o representante..."
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
|
||
|
||
export function OrderDetailPage() {
|
||
const { id } = useParams({ from: '/pedidos/$id' });
|
||
const navigate = useNavigate();
|
||
const qc = useQueryClient();
|
||
const { data: order, isLoading, error } = useOrderDetail(id);
|
||
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
||
|
||
const role = getRoleFromToken();
|
||
const isErp = order?.fonte === 'erp';
|
||
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
|
||
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
||
const canShare =
|
||
role === 'rep' &&
|
||
(order?.situa === 2 || order?.situa === 4) &&
|
||
typeof navigator !== 'undefined' &&
|
||
!!navigator.share;
|
||
|
||
const [approveOpen, setApproveOpen] = useState(false);
|
||
const [rejectOpen, setRejectOpen] = useState(false);
|
||
const [actionError, setActionError] = useState<string | null>(null);
|
||
|
||
const approveMutation = useMutation({
|
||
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
|
||
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
|
||
onSuccess: () => {
|
||
setApproveOpen(false);
|
||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||
},
|
||
onError: (e: unknown) => {
|
||
setApproveOpen(false);
|
||
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
|
||
},
|
||
});
|
||
|
||
const rejectMutation = useMutation({
|
||
mutationFn: (motivo: string) =>
|
||
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
|
||
onSuccess: () => {
|
||
setRejectOpen(false);
|
||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||
},
|
||
onError: (e: unknown) => {
|
||
setRejectOpen(false);
|
||
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
|
||
},
|
||
});
|
||
|
||
const transmitMutation = useMutation({
|
||
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
|
||
onSuccess: () => {
|
||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||
},
|
||
// Mensagem de bloqueio de alçada (desconto acima do máximo) vem aqui.
|
||
onError: (e: unknown) => setActionError(e instanceof Error ? e.message : 'Erro ao transmitir'),
|
||
});
|
||
|
||
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||
if (error || !order)
|
||
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||
|
||
const timeWaiting =
|
||
order.situa === 1
|
||
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
||
: null;
|
||
|
||
// Orçamento: tela mais larga para consulta/revisão com o cliente.
|
||
const isOrcamento = order.situa === 0;
|
||
|
||
return (
|
||
<div style={{ padding: 24, maxWidth: isOrcamento ? 1320 : 960, margin: '0 auto' }}>
|
||
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||
<Link to="/pedidos">← Pedidos</Link>
|
||
<Title level={3} style={{ margin: 0 }}>
|
||
{order.numPedSar}
|
||
</Title>
|
||
<Badge
|
||
status={
|
||
(SITUA_COLOR[order.situa] ?? 'default') as
|
||
| 'default'
|
||
| 'warning'
|
||
| 'processing'
|
||
| 'success'
|
||
| 'error'
|
||
}
|
||
text={
|
||
<Tag color={SITUA_COLOR[order.situa] ?? 'default'}>
|
||
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||
</Tag>
|
||
}
|
||
/>
|
||
{timeWaiting !== null && timeWaiting > 2 && (
|
||
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
||
)}
|
||
<Button
|
||
icon={<FilePdfOutlined />}
|
||
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
|
||
>
|
||
Gerar PDF
|
||
</Button>
|
||
{canTransmit && (
|
||
<Button
|
||
type="primary"
|
||
loading={transmitMutation.isPending}
|
||
onClick={() => {
|
||
setActionError(null);
|
||
transmitMutation.mutate();
|
||
}}
|
||
style={{ backgroundColor: '#389e0d', borderColor: '#389e0d' }}
|
||
>
|
||
Transmitir pedido
|
||
</Button>
|
||
)}
|
||
{canAct && (
|
||
<Space>
|
||
<Button type="primary" onClick={() => setApproveOpen(true)}>
|
||
Aprovar
|
||
</Button>
|
||
<Button danger onClick={() => setRejectOpen(true)}>
|
||
Recusar
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
{canShare && (
|
||
<Button
|
||
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.share({ text: buildShareText(order) });
|
||
} catch {
|
||
void message.info('Compartilhamento cancelado');
|
||
}
|
||
}}
|
||
>
|
||
Compartilhar
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
|
||
{actionError && (
|
||
<Alert
|
||
type="error"
|
||
message={actionError}
|
||
showIcon
|
||
closable
|
||
onClose={() => setActionError(null)}
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
)}
|
||
|
||
<Descriptions
|
||
bordered
|
||
size={isOrcamento ? 'middle' : 'small'}
|
||
column={isOrcamento ? 3 : 2}
|
||
style={{ marginBottom: 24 }}
|
||
>
|
||
<Descriptions.Item label="Cliente">
|
||
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
|
||
{order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`}
|
||
</Link>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="Representante">
|
||
{order.nomeVendedor ?? `Cód. ${order.codVendedor}`}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="Data">
|
||
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||
</Descriptions.Item>
|
||
{order.aprovadoEm && (
|
||
<Descriptions.Item label="Aprovado em">
|
||
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
||
</Descriptions.Item>
|
||
)}
|
||
{order.formaPagamento && (
|
||
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
||
{order.formaPagamento}
|
||
</Descriptions.Item>
|
||
)}
|
||
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||
<Descriptions.Item label="Total">
|
||
<Text strong style={{ fontSize: 16 }}>
|
||
{fmt(order.total)}
|
||
</Text>
|
||
</Descriptions.Item>
|
||
{order.obs && (
|
||
<Descriptions.Item label="Observações" span={2}>
|
||
{order.obs}
|
||
</Descriptions.Item>
|
||
)}
|
||
{order.motivoRecusa && (
|
||
<Descriptions.Item label="Motivo Recusa" span={2}>
|
||
<Text type="danger">{order.motivoRecusa}</Text>
|
||
</Descriptions.Item>
|
||
)}
|
||
</Descriptions>
|
||
|
||
<Divider orientation="left">Itens ({order.itens.length})</Divider>
|
||
<Table<PedidoItem>
|
||
rowKey="id"
|
||
columns={itemColumns}
|
||
dataSource={order.itens}
|
||
pagination={false}
|
||
size={isOrcamento ? 'middle' : 'small'}
|
||
style={{ marginBottom: 24 }}
|
||
/>
|
||
|
||
{clientOrders && clientOrders.length > 0 && (
|
||
<>
|
||
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={clientOrders.filter((o) => o.id !== id).slice(0, 5)}
|
||
columns={[
|
||
{
|
||
title: 'Nº',
|
||
dataIndex: 'numPedSar',
|
||
width: 110,
|
||
render: (n: string, r: { id: string }) => (
|
||
<Link to="/pedidos/$id" params={{ id: r.id }}>
|
||
{n}
|
||
</Link>
|
||
),
|
||
},
|
||
{
|
||
title: 'Status',
|
||
dataIndex: 'situa',
|
||
width: 130,
|
||
render: (s: number) => (
|
||
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: 'Total',
|
||
dataIndex: 'total',
|
||
align: 'right' as const,
|
||
render: (v: string) => fmt(v),
|
||
},
|
||
{
|
||
title: 'Data',
|
||
dataIndex: 'dtPedido',
|
||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||
},
|
||
]}
|
||
style={{ marginBottom: 24 }}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
<Divider orientation="left">Histórico do Pedido</Divider>
|
||
<HistoryTimeline history={order.historico} />
|
||
|
||
<ApproveModal
|
||
open={approveOpen}
|
||
originalDiscount={order.descontoPerc}
|
||
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
|
||
onCancel={() => setApproveOpen(false)}
|
||
loading={approveMutation.isPending}
|
||
/>
|
||
<RejectModal
|
||
open={rejectOpen}
|
||
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
|
||
onCancel={() => setRejectOpen(false)}
|
||
loading={rejectMutation.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|