feat(auth): endpoint /auth/me, cockpits renomeados e menu de logout
- GET /api/v1/auth/me retorna perfil real do ERP (vw_representantes) - Contrato UserProfile adicionado ao shared api-interface - Hook useCurrentUser() no frontend consome o endpoint - Cockpit rafael → rep, sandra → supervisor (pastas e componentes) - Topbar exibe iniciais do usuário e dropdown com nome, role e "Sair" - Logout limpa token e recarrega para voltar ao DevLogin Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
468
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
468
apps/web/src/cockpits/rep/OrderDetailPage.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
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 type { TableColumnsType } from 'antd';
|
||||
import { Link, useParams } 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> = {
|
||||
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 qc = useQueryClient();
|
||||
const { data: order, isLoading, error } = useOrderDetail(id);
|
||||
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
||||
|
||||
const role = getRoleFromToken();
|
||||
const canAct = role !== 'rep' && order?.situa === 1;
|
||||
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');
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 960 }}>
|
||||
<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>
|
||||
)}
|
||||
{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="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="Cliente">
|
||||
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
|
||||
Cód. {order.idCliente}
|
||||
</Link>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Rep (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>
|
||||
)}
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user