Files
sar/apps/web/src/cockpits/rep/OrderDetailPage.tsx
julian 6fbf8bfb8e feat(orders): detalhe completo de pedidos ERP com produtos e pagamento
- 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>
2026-05-30 21:49:53 +00:00

518 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}