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:
2026-05-29 17:48:24 +00:00
parent 20b0793227
commit a00a5c6a53
16 changed files with 156 additions and 33 deletions

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { Table, Input, Select, Space, Typography, Tag } from 'antd';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
const { Title } = Typography;
const { Search } = Input;
function fmtPrice(v: string | null | undefined): string {
const n = Number(v ?? 0);
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
}
const columns: TableColumnsType<ProdutoSummary> = [
{
title: 'Código',
dataIndex: 'codigo',
width: 110,
render: (v: string) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.trim()}</span>,
},
{
title: 'Descrição',
dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => (
<div>
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
</div>
),
},
{
title: 'Und',
dataIndex: 'unidade',
width: 60,
align: 'center',
render: (v: string | null) => v ?? '—',
},
{
title: 'Marca',
dataIndex: 'marca',
width: 130,
render: (v: string | null) => (v ? <Tag>{v.trim()}</Tag> : null),
},
{
title: 'Preço',
dataIndex: 'vlPreco1',
width: 120,
align: 'right',
render: (v: string) => (
<span style={{ fontWeight: 600, color: Number(v) > 0 ? '#389e0d' : '#999' }}>
{fmtPrice(v)}
</span>
),
},
{
title: 'Estoque',
dataIndex: 'qtdEstoque',
width: 90,
align: 'right',
render: (v: string | null) => {
if (v == null) return '—';
const n = Number(v);
return (
<span style={{ color: n > 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')}</span>
);
},
},
];
export function CatalogPage() {
const [q, setQ] = useState('');
const [idPauta, setIdPauta] = useState<number | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 16 }}>
Catálogo de Produtos
</Title>
<Space style={{ marginBottom: 16 }} wrap>
<Search
placeholder="Buscar por código ou descrição..."
allowClear
style={{ width: 300 }}
onSearch={(v) => {
setQ(v);
setPage(1);
}}
onChange={(e) => {
if (!e.target.value) {
setQ('');
setPage(1);
}
}}
/>
<Select
placeholder="Selecionar pauta de preços"
allowClear
loading={pautasLoading}
style={{ width: 340 }}
onChange={(v) => {
setIdPauta(v as number | undefined);
setPage(1);
}}
options={pautas?.map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
}))}
/>
</Space>
<Table<ProdutoSummary>
rowKey="idErp"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
size="small"
pagination={{
current: page,
pageSize: limit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
onChange: (p) => setPage(p),
}}
/>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
const { Title } = Typography;
const ACTIVITY_COLOR: Record<string, string> = {
active: 'success',
alert: 'warning',
inactive: 'default',
};
const ACTIVITY_LABEL: Record<string, string> = {
active: 'Ativo',
alert: 'Alerta',
inactive: 'Inativo',
};
const orderColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Status',
dataIndex: 'situa',
width: 140,
render: (s: number) => {
const colorMap: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
},
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id);
const navigate = useNavigate();
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (clientError || !client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
return (
<div style={{ padding: 24 }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
{client.razao ?? client.nome}
</Title>
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
{ACTIVITY_LABEL[client.activityStatus]}
</Tag>
<Button
type="primary"
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
>
Novo Pedido
</Button>
</Space>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Telefone">
{client.ddd ? `(${client.ddd}) ` : ''}
{client.telefone ?? '—'}
</Descriptions.Item>
{client.endereco && (
<Descriptions.Item label="Endereço" span={2}>
{client.endereco}
{client.numEndereco ? `, ${client.numEndereco}` : ''}
{client.bairro ? `${client.bairro}` : ''}
{client.cep ? ` — CEP ${client.cep}` : ''}
</Descriptions.Item>
)}
<Descriptions.Item label="Limite de Crédito">
{client.limiteCreditoStr
? Number(client.limiteCreditoStr).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})
: '—'}
</Descriptions.Item>
<Descriptions.Item label="Última Compra">
{client.dtUltimaCompra
? new Date(client.dtUltimaCompra).toLocaleDateString('pt-BR')
: '—'}
</Descriptions.Item>
</Descriptions>
<Divider orientation="left">Últimos Pedidos</Divider>
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns}
dataSource={orders ?? []}
loading={ordersLoading}
pagination={false}
size="small"
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useState } from 'react';
import { Badge, Input, Select, Space, Table, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
import { useClientList } from '../../lib/queries/clients';
const { Title } = Typography;
const { Search } = Input;
// ─── Badge configs ────────────────────────────────────────────────────────────
const ACTIVITY_CONFIG: Record<ActivityStatus, { color: string; label: string }> = {
active: { color: 'success', label: 'Ativo' },
alert: { color: 'warning', label: 'Em alerta' },
inactive: { color: 'error', label: 'Inativo' },
};
// ─── Columns ──────────────────────────────────────────────────────────────────
function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsType<ClientSummary> {
return [
{
title: 'Cliente',
dataIndex: 'nome',
key: 'nome',
render: (nome: string, record: ClientSummary) => (
<Space orientation="vertical" size={0}>
<Typography.Link
strong
onClick={() =>
navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } })
}
>
{nome}
</Typography.Link>
{record.razao && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{record.razao}
</Typography.Text>
)}
</Space>
),
sorter: true,
},
{
title: 'CNPJ / CPF',
dataIndex: 'cgcpf',
key: 'cgcpf',
width: 160,
render: (v: string | null) => (
<Typography.Text className="tabular-nums" style={{ fontSize: 13 }}>
{v ?? '—'}
</Typography.Text>
),
},
{
title: 'Atividade',
dataIndex: 'activityStatus',
key: 'activityStatus',
width: 120,
render: (v: ActivityStatus) => {
const cfg = ACTIVITY_CONFIG[v];
return <Badge status={cfg.color as 'success' | 'warning' | 'error'} text={cfg.label} />;
},
},
{
title: 'Última compra',
dataIndex: 'dtUltimaCompra',
key: 'dtUltimaCompra',
width: 140,
render: (v: string | null) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
return (
<Typography.Text className="tabular-nums">
{new Date(v).toLocaleDateString('pt-BR')}
</Typography.Text>
);
},
},
];
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function ClientsPage() {
const navigate = useNavigate();
const [q, setQ] = useState('');
const [search, setSearch] = useState('');
const [activityFilter, setActivityFilter] = useState<ActivityStatus | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data, isLoading, isFetching } = useClientList({
q: search || undefined,
status: activityFilter,
page,
limit,
});
const columns = buildColumns(navigate);
return (
<Space orientation="vertical" size={24} style={{ width: '100%' }}>
{/* Cabeçalho */}
<Space orientation="vertical" size={4}>
<Title level={2} style={{ margin: 0 }}>
Carteira de Clientes
</Title>
<Typography.Text type="secondary">
{data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '}
</Typography.Text>
</Space>
{/* Filtros */}
<Space wrap>
<Search
placeholder="Buscar por nome, razão social ou CNPJ…"
value={q}
onChange={(e) => setQ(e.target.value)}
onSearch={(v) => {
setSearch(v);
setPage(1);
}}
allowClear
style={{ width: 320 }}
/>
<Select<ActivityStatus | undefined>
placeholder="Atividade"
allowClear
style={{ width: 140 }}
value={activityFilter}
onChange={(v) => {
setActivityFilter(v);
setPage(1);
}}
options={[
{ value: 'active', label: 'Ativo' },
{ value: 'alert', label: 'Em alerta' },
{ value: 'inactive', label: 'Inativo' },
]}
/>
</Space>
{/* Tabela */}
<Table<ClientSummary>
columns={columns}
dataSource={data?.data ?? []}
rowKey="idCliente"
loading={isLoading || isFetching}
pagination={{
current: page,
pageSize: limit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (total) => `${total} clientes`,
onChange: (p) => setPage(p),
}}
scroll={{ x: 700 }}
size="middle"
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () =>
navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } }),
})}
/>
</Space>
);
}

View File

@@ -0,0 +1,430 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Alert,
Button,
Descriptions,
Divider,
Form,
InputNumber,
Space,
Spin,
Steps,
Table,
Tag,
Typography,
Input,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import type { CreatePedido, CreatePedidoItem, ProdutoSummary } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useCatalog } from '../../lib/queries/catalog';
import { apiFetch } from '../../lib/api-client';
const { Title, Text } = Typography;
const { Search } = Input;
type CartItem = CreatePedidoItem & { key: string };
function calcItemTotal(qty: number, price: number, disc: number): number {
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
}
function fmt(n: number): string {
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// ─── Step 1 — Selecionar Produtos ────────────────────────────────────────────
function ProductStep({
cart,
onAdd,
onRemove,
onQtyChange,
onDiscChange,
}: {
cart: CartItem[];
onAdd: (p: ProdutoSummary) => void;
onRemove: (key: string) => void;
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
}) {
const [q, setQ] = useState('');
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
const cartKeys = new Set(cart.map((c) => String(c.idProduto)));
const catalogColumns: TableColumnsType<ProdutoSummary> = [
{ title: 'Código', dataIndex: 'codigo', width: 100 },
{ title: 'Produto', dataIndex: 'descricao', ellipsis: true },
{
title: 'Grupo',
dataIndex: 'grupo',
width: 110,
render: (v: string | null) => (v ? <Tag>{v}</Tag> : null),
},
{
title: 'Preço',
dataIndex: 'vlPreco1',
width: 110,
align: 'right',
render: (v: string) => fmt(Number(v)),
},
{
title: '',
width: 80,
render: (_: unknown, row: ProdutoSummary) => (
<Button
size="small"
icon={<PlusOutlined />}
disabled={cartKeys.has(String(row.idErp))}
onClick={() => onAdd(row)}
>
Add
</Button>
),
},
];
const cartColumns: TableColumnsType<CartItem> = [
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
{
title: 'Qtd',
dataIndex: 'qtd',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
min={0.001}
step={1}
value={v}
size="small"
style={{ width: 80 }}
onChange={(n) => onQtyChange(row.key, n ?? 1)}
/>
),
},
{
title: 'Desc %',
dataIndex: 'descontoPerc',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
min={0}
max={100}
step={0.5}
value={v}
size="small"
style={{ width: 80 }}
onChange={(n) => onDiscChange(row.key, n ?? 0)}
/>
),
},
{
title: 'Subtotal',
width: 120,
align: 'right',
render: (_: unknown, row: CartItem) =>
fmt(calcItemTotal(row.qtd, row.precoUnitario, row.descontoPerc)),
},
{
title: '',
width: 40,
render: (_: unknown, row: CartItem) => (
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.key)} />
),
},
];
return (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Search
placeholder="Buscar produto por nome ou código..."
allowClear
style={{ maxWidth: 400 }}
onSearch={setQ}
onChange={(e) => {
if (!e.target.value) setQ('');
}}
/>
<Table<ProdutoSummary>
rowKey="idErp"
columns={catalogColumns}
dataSource={data?.data ?? []}
loading={isLoading}
size="small"
pagination={false}
scroll={{ y: 220 }}
/>
<Divider orientation="left">Itens do Pedido ({cart.length})</Divider>
<Table<CartItem>
rowKey="key"
columns={cartColumns}
dataSource={cart}
size="small"
pagination={false}
locale={{ emptyText: 'Nenhum produto adicionado ainda.' }}
/>
</Space>
);
}
// ─── Step 2 — Desconto Global + Observações ───────────────────────────────────
function ReviewStep({
cart,
globalDisc,
obs,
creditLimit,
onDiscChange,
onObsChange,
}: {
cart: CartItem[];
globalDisc: number;
obs: string;
creditLimit: string | null;
onDiscChange: (v: number) => void;
onObsChange: (v: string) => void;
}) {
const itemsSubtotal = cart.reduce(
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
0,
);
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
const limit = creditLimit ? Number(creditLimit) : null;
const creditOk = limit === null || total <= limit;
return (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="Subtotal dos itens" span={2}>
<Text strong>{fmt(itemsSubtotal)}</Text>
</Descriptions.Item>
<Descriptions.Item label="Desconto global do pedido">
<InputNumber
min={0}
max={100}
step={0.5}
value={globalDisc}
addonAfter="%"
style={{ width: 120 }}
onChange={(n) => onDiscChange(n ?? 0)}
/>
</Descriptions.Item>
<Descriptions.Item label="Total do pedido">
<Text strong style={{ fontSize: 16 }}>
{fmt(total)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="Limite de crédito">{limit ? fmt(limit) : '—'}</Descriptions.Item>
<Descriptions.Item label="Situação crédito">
<Tag color={creditOk ? 'success' : 'error'}>{creditOk ? 'OK' : 'Acima do limite'}</Tag>
</Descriptions.Item>
</Descriptions>
<Form.Item label="Observações (opcional)">
<Input.TextArea
rows={3}
maxLength={500}
showCount
value={obs}
onChange={(e) => onObsChange(e.target.value)}
placeholder="Instruções de entrega, referência do comprador, etc."
/>
</Form.Item>
</Space>
);
}
// ─── Step 3 — Confirmação ─────────────────────────────────────────────────────
function ConfirmStep({
cart,
globalDisc,
obs,
clientNome,
}: {
cart: CartItem[];
globalDisc: number;
obs: string;
clientNome: string;
}) {
const itemsSubtotal = cart.reduce(
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
0,
);
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
return (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Cliente">{clientNome}</Descriptions.Item>
<Descriptions.Item label="Produtos">{cart.length} item(ns)</Descriptions.Item>
<Descriptions.Item label="Subtotal dos itens">{fmt(itemsSubtotal)}</Descriptions.Item>
<Descriptions.Item label="Desconto global">{globalDisc}%</Descriptions.Item>
<Descriptions.Item label="Total">
<Text strong style={{ fontSize: 18 }}>
{fmt(total)}
</Text>
</Descriptions.Item>
{obs && <Descriptions.Item label="Observações">{obs}</Descriptions.Item>}
</Descriptions>
<Alert
type="info"
message="O pedido será criado como Pendente de Aprovação ou Aprovado conforme a sua alçada de desconto."
showIcon
/>
</Space>
);
}
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
type SearchParams = { clientId?: string };
export function NewOrderPage() {
const { clientId } = useSearch({ strict: false }) as SearchParams;
const navigate = useNavigate();
const qc = useQueryClient();
const clientIdNum = clientId ? Number(clientId) : undefined;
const { data: client, isLoading: clientLoading } = useClientDetail(clientIdNum);
const [step, setStep] = useState(0);
const [cart, setCart] = useState<CartItem[]>([]);
const [globalDisc, setGlobalDisc] = useState(0);
const [obs, setObs] = useState('');
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async () => {
if (!clientIdNum) throw new Error('clientId ausente');
const body: CreatePedido = {
idCliente: clientIdNum,
descontoPerc: globalDisc,
obs: obs || undefined,
idempotencyKey: crypto.randomUUID(),
itens: cart.map((it, idx) => ({
idProduto: it.idProduto,
codProduto: it.codProduto,
descProduto: it.descProduto,
ordem: idx + 1,
qtd: it.qtd,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
})),
};
return apiFetch('/orders', { method: 'POST', body });
},
onSuccess: (pedido: { id: string }) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients', clientIdNum] });
void navigate({ to: '/pedidos/$id', params: { id: pedido.id } });
},
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
});
const addToCart = (p: ProdutoSummary) => {
setCart((prev) => [
...prev,
{
key: String(p.idErp),
idProduto: p.idErp,
codProduto: p.codigo,
descProduto: p.descricao,
ordem: prev.length + 1,
qtd: 1,
precoUnitario: Number(p.vlPreco1),
descontoPerc: 0,
},
]);
};
const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
const setQty = (key: string, qty: number) =>
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, qtd: qty } : it)));
const setDisc = (key: string, disc: number) =>
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, descontoPerc: disc } : it)));
if (!clientId)
return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />;
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (!client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
const steps = [{ title: 'Produtos' }, { title: 'Desconto / Obs.' }, { title: 'Confirmar' }];
const canNext = (step === 0 && cart.length > 0) || step === 1;
return (
<div style={{ padding: 24, maxWidth: 900 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Link to="/clientes/$id" params={{ id: clientId }}>
{client.razao ?? client.nome}
</Link>
<Title level={3} style={{ margin: 0 }}>
Novo Pedido
</Title>
</Space>
<Steps current={step} items={steps} style={{ marginBottom: 24 }} />
{step === 0 && (
<ProductStep
cart={cart}
onAdd={addToCart}
onRemove={removeFromCart}
onQtyChange={setQty}
onDiscChange={setDisc}
/>
)}
{step === 1 && (
<ReviewStep
cart={cart}
globalDisc={globalDisc}
obs={obs}
creditLimit={client.limiteCreditoStr}
onDiscChange={setGlobalDisc}
onObsChange={setObs}
/>
)}
{step === 2 && (
<ConfirmStep
cart={cart}
globalDisc={globalDisc}
obs={obs}
clientNome={client.razao ?? client.nome}
/>
)}
{error && (
<Alert
type="error"
message={error}
showIcon
style={{ marginTop: 16 }}
closable
onClose={() => setError(null)}
/>
)}
<Divider />
<Space>
{step > 0 && <Button onClick={() => setStep((s) => s - 1)}>Voltar</Button>}
{step < 2 && (
<Button type="primary" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
Próximo
</Button>
)}
{step === 2 && (
<Button type="primary" loading={mutation.isPending} onClick={() => mutation.mutate()}>
Confirmar Pedido
</Button>
)}
</Space>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,142 @@
import { useState } from 'react';
import { Table, Tag, Input, Select, Space, Typography, Badge } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderList } from '../../lib/queries/orders';
const { Title } = Typography;
const { Search } = Input;
const SITUA_COLOR: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numero',
width: 120,
render: (_: number, row: PedidoSummary) => {
const label = row.numero ? String(row.numero) : row.numPedSar || row.id;
return row.fonte === 'erp' ? (
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{label}</span>
) : (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{label}
</Link>
);
},
},
{
title: 'Status',
dataIndex: 'situa',
width: 150,
render: (s: number, row: PedidoSummary) => {
const label = row.statusDescr ?? SITUA_LABEL[s] ?? String(s);
return (
<Badge
status={
(SITUA_COLOR[s] ?? 'default') as
| 'default'
| 'warning'
| 'processing'
| 'success'
| 'error'
}
text={<Tag color={SITUA_COLOR[s] ?? 'default'}>{label}</Tag>}
/>
);
},
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function OrdersPage() {
const [numFilter, setNumFilter] = useState('');
const [situaFilter, setSituaFilter] = useState<number | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data, isLoading } = useOrderList({
numPedSar: numFilter || undefined,
situa: situaFilter,
page,
limit,
});
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 16 }}>
Pedidos
</Title>
<Space style={{ marginBottom: 16 }} wrap>
<Search
placeholder="Buscar por número (SAR-NNNNN)..."
allowClear
style={{ width: 240 }}
onSearch={(v) => {
setNumFilter(v);
setPage(1);
}}
onChange={(e) => {
if (!e.target.value) {
setNumFilter('');
setPage(1);
}
}}
/>
<Select
placeholder="Status"
allowClear
style={{ width: 160 }}
onChange={(v) => {
setSituaFilter(v as number | undefined);
setPage(1);
}}
options={[
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Aprovado' },
{ value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' },
]}
/>
</Space>
<Table<PedidoSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
pagination={{
current: page,
pageSize: limit,
total: data?.total ?? 0,
showSizeChanger: false,
onChange: (p) => setPage(p),
}}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
</div>
);
}

View File

@@ -0,0 +1,280 @@
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Tag, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowTrendUp,
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography;
const SITUA_COLOR: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function greeting(): string {
const h = new Date().getHours();
if (h < 12) return 'Bom dia';
if (h < 18) return 'Boa tarde';
return 'Boa noite';
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', {
day: 'numeric',
month: 'long',
});
}
export function RepPainel() {
const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser();
if (isLoading || !data) {
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 2 }} />
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
</Row>
</Flex>
);
}
const { meta, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } = data;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Saudação */}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
{clientesInativos.length > 0 && (
<>
{' '}
·{' '}
<span style={{ color: 'var(--orange)' }}>
{clientesInativos.length} clientes inativos
</span>
</>
)}
</Text>
</Flex>
{/* Linha 1 — Meta + KPIs */}
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Card style={{ height: '100%' }}>
<Flex vertical gap={16}>
<Flex justify="space-between" align="flex-start">
<Space orientation="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(meta.atingido)}
</Title>
<Text type="secondary">
de <span className="tabular-nums">{fmt(meta.total)}</span>
</Text>
</Space>
<Tag
color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}
>
{meta.pct}% atingido
</Tag>
</Flex>
<Progress
percent={Math.min(meta.pct, 100)}
showInfo={false}
strokeColor="var(--jcs-blue)"
trailColor="var(--jcs-blue-light)"
/>
{meta.falta > 0 ? (
<Text style={{ fontSize: 'var(--text-md)' }}>
Faltam <strong className="tabular-nums">{fmt(meta.falta)}</strong> pra fechar o
mês.
</Text>
) : (
<Text style={{ fontSize: 'var(--text-md)', color: 'var(--green)' }}>
Meta batida! Comissão FLEX ativa.
</Text>
)}
</Flex>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
</Text>
</Space>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
COMISSÃO ACUMULADA
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(comissao.total)}
</Title>
{comissao.flex > 0 && (
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: {fmt(comissao.flex)}
</Text>
)}
</Space>
</Card>
</Col>
</Row>
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Clientes esfriando
</Space>
}
extra={
clientesInativos.length > 0 ? (
<Text type="secondary">{clientesInativos.length} clientes</Text>
) : null
}
>
{clientesInativos.length === 0 ? (
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
{clientesInativos.map((c) => (
<Flex
key={c.idCliente}
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
}}
>
<Space orientation="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
<Text strong>{c.nome}</Text>
</Link>
{c.ultimaCompraValor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra:{' '}
<span className="tabular-nums">
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</span>
</Text>
)}
</Space>
<Tag
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
className="tabular-nums"
>
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
</Tag>
</Flex>
))}
</Flex>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
Pedidos recentes
</Space>
}
extra={<Link to="/pedidos">Ver todos</Link>}
>
{pedidosRecentes.length === 0 ? (
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
) : (
<Flex vertical gap={10}>
{pedidosRecentes.map((o: PedidoSummary) => (
<Flex key={o.id} justify="space-between" align="center">
<Space orientation="vertical" size={0}>
<Link to="/pedidos/$id" params={{ id: o.id }}>
<Text strong className="tabular-nums">
{o.numPedSar}
</Text>
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Cód. cliente {o.idCliente}
</Text>
</Space>
<Flex gap={8} align="center">
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{Number(o.total).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</Text>
<Tag color={SITUA_COLOR[o.situa] ?? 'default'}>
{SITUA_LABEL[o.situa] ?? String(o.situa)}
</Tag>
</Flex>
</Flex>
))}
</Flex>
)}
</Card>
</Col>
</Row>
<Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas
</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
</Text>
</Flex>
</Flex>
);
}