Files
sar/apps/web/src/cockpits/rep/OrdersPage.tsx
julian fb6df551b7 feat(web): redesign NewOrderPage e OrdersPage + botão Novo Pedido global
NewOrderPage:
- Layout de página única com cards (remove wizard em steps)
- AutoComplete de cliente com busca na API
- Badge de confirmação ao selecionar cliente
- Select de Pauta (API real) e Condição de Pagamento (mock)
- Campos Contato e Nº OC
- AutoComplete de produto por catálogo com pauta aplicada
- Soma qty automaticamente se produto já está no carrinho
- Tabela de itens com qty/desconto editáveis inline
- Rodapé fixo com total e botão Finalizar verde

OrdersPage:
- Cards de métricas (total, vendido, pendentes, aprovados, ticket médio)
- Filtros por status e período (hoje / 7d / 30d)
- Tabela com row-click colorido por status
- Drawer lateral com detalhes, itens e timeline de histórico
- Menu de ações por linha (ver, duplicar, PDF, cancelar)
- Cards mobile responsivos

Layout global:
- Botão Novo Pedido na Topbar (sempre visível)
- FAB verde fixo (bottom-right) no AppShell

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 18:48:01 +00:00

777 lines
25 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 {
Button,
Card,
Col,
Drawer,
Dropdown,
Empty,
Grid,
Row,
Select,
Space,
Spin,
Table,
Tag,
Timeline,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd';
import {
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
CopyOutlined,
DollarOutlined,
EllipsisOutlined,
EyeOutlined,
FilePdfOutlined,
PlusOutlined,
ShoppingCartOutlined,
} from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(v: number | string) {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string) {
return new Date(v).toLocaleDateString('pt-BR');
}
function toISO(d: Date) {
return d.toISOString().split('T')[0];
}
function periodRange(p: string): { from?: string; to?: string } {
const today = new Date();
if (p === 'today') return { from: toISO(today), to: toISO(today) };
if (p === '7d') {
const d = new Date(today);
d.setDate(d.getDate() - 7);
return { from: toISO(d), to: toISO(today) };
}
if (p === '30d') {
const d = new Date(today);
d.setDate(d.getDate() - 30);
return { from: toISO(d), to: toISO(today) };
}
return {};
}
// ─── Status ───────────────────────────────────────────────────────────────────
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
1: { label: 'Ag. Aprovação', color: '#d46b08', rowBg: '#fffbe6', tagColor: 'orange' },
2: { label: 'Aprovado', color: '#389e0d', rowBg: '#f6ffed', tagColor: 'green' },
3: { label: 'Cancelado', color: '#cf1322', rowBg: '#fff1f0', tagColor: 'red' },
4: { label: 'Faturado', color: '#1d39c4', rowBg: '#f0f5ff', tagColor: 'geekblue' },
};
// ─── OrderStatusBadge ─────────────────────────────────────────────────────────
function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
const cfg = STATUS[situa];
const label = descr ?? cfg?.label ?? SITUA_LABEL[situa] ?? String(situa);
return (
<Tag
color={cfg?.tagColor ?? 'default'}
style={{ borderRadius: 20, fontWeight: 600, fontSize: 11, padding: '1px 10px' }}
>
{label}
</Tag>
);
}
// ─── OrdersMetrics ────────────────────────────────────────────────────────────
function OrdersMetrics({ data }: { data: PedidoSummary[] }) {
const total = data.reduce((a, o) => a + Number(o.total), 0);
const pendentes = data.filter((o) => o.situa === 1).length;
const aprovados = data.filter((o) => o.situa === 2).length;
const ticket = data.length > 0 ? total / data.length : 0;
const metrics = [
{
label: 'Total de Pedidos',
value: String(data.length),
icon: <ShoppingCartOutlined />,
color: '#003B8E',
},
{ label: 'Total Vendido', value: fmt(total), icon: <DollarOutlined />, color: '#389e0d' },
{
label: 'Ag. Aprovação',
value: String(pendentes),
icon: <ClockCircleOutlined />,
color: '#d46b08',
},
{
label: 'Aprovados',
value: String(aprovados),
icon: <CheckCircleOutlined />,
color: '#389e0d',
},
{ label: 'Ticket Médio', value: fmt(ticket), icon: <DollarOutlined />, color: '#1d39c4' },
];
return (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{metrics.map((m) => (
<Col key={m.label} xs={12} sm={8} md={6} lg={24 / metrics.length}>
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: '14px 18px' } }}
>
<Space size={10} align="center">
<span style={{ fontSize: 20, color: m.color }}>{m.icon}</span>
<div>
<Text
style={{
fontSize: 11,
color: '#64748B',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
display: 'block',
}}
>
{m.label}
</Text>
<Text strong style={{ fontSize: 18, color: '#1F2937', lineHeight: 1.2 }}>
{m.value}
</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
);
}
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
function OrderActionsMenu({
order,
onView,
}: {
order: PedidoSummary;
onView: (id: string) => void;
}) {
const navigate = useNavigate();
const canDetail = order.fonte !== 'erp';
const items: MenuProps['items'] = [
canDetail
? {
key: 'view',
icon: <EyeOutlined />,
label: 'Ver detalhes',
onClick: () => onView(order.id),
}
: { key: 'view', icon: <EyeOutlined />, label: 'Ver detalhes', disabled: true },
{
key: 'duplicate',
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
label: <span style={{ color: '#0057D9', fontWeight: 600 }}>Duplicar pedido</span>,
onClick: () =>
void navigate({ to: '/pedidos/novo', search: { clientId: String(order.idCliente) } }),
},
{ type: 'divider' },
{
key: 'pdf',
icon: <FilePdfOutlined />,
label: 'Gerar PDF',
onClick: () => alert('PDF em breve'),
},
{
key: 'cancel',
icon: <CloseCircleOutlined />,
label: 'Cancelar pedido',
danger: true,
disabled: order.situa === 3,
onClick: () => alert('Cancelamento em breve'),
},
];
return (
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
<Button type="text" icon={<EllipsisOutlined />} size="small" />
</Dropdown>
);
}
// ─── OrderDetailDrawer ────────────────────────────────────────────────────────
function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
const { data, isLoading } = useOrderDetail(id ?? undefined);
const timelineItems = (data?.historico ?? []).map((h) => ({
dot:
h.situaNova === 2 ? (
<CheckCircleOutlined style={{ color: '#389e0d' }} />
) : h.situaNova === 3 ? (
<CloseCircleOutlined style={{ color: '#cf1322' }} />
) : (
<ClockCircleOutlined style={{ color: '#d46b08' }} />
),
children: (
<span style={{ fontSize: 13 }}>
<Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text>
{' — '}
{h.situaAnterior === null ? (
<strong>Pedido criado</strong>
) : (
<>
Status alterado para{' '}
<strong>{STATUS[h.situaNova]?.label ?? SITUA_LABEL[h.situaNova] ?? h.situaNova}</strong>
</>
)}
{h.nota && <Text type="secondary"> · {h.nota}</Text>}
</span>
),
}));
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#64748B',
marginBottom: 2,
display: 'block',
};
return (
<Drawer
title={data ? `Pedido ${data.numPedSar}` : 'Detalhes do Pedido'}
open={!!id}
onClose={onClose}
width={520}
styles={{ body: { padding: '16px 24px' } }}
footer={
<Space>
<Button onClick={onClose}>Fechar</Button>
{data && (
<Button type="primary" onClick={() => alert('PDF em breve')}>
Gerar PDF
</Button>
)}
</Space>
}
>
{isLoading && <Spin style={{ display: 'block', marginTop: 48, textAlign: 'center' }} />}
{data && (
<Space direction="vertical" size={20} style={{ width: '100%' }}>
{/* Status */}
<div>
<OrderStatusBadge situa={data.situa} descr={data.statusDescr} />
</div>
{/* Dados principais */}
<Card
styles={{ body: { padding: '14px 16px' } }}
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
>
<Row gutter={[16, 10]}>
<Col span={12}>
<span style={label}>Pedido</span>
<Text strong>{data.numPedSar}</Text>
</Col>
<Col span={12}>
<span style={label}>Data</span>
<Text>{fmtDate(data.dtPedido)}</Text>
</Col>
<Col span={12}>
<span style={label}>Cód. Cliente</span>
<Text>{data.idCliente}</Text>
</Col>
<Col span={12}>
<span style={label}>Total</span>
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
{fmt(data.total)}
</Text>
</Col>
{data.obs && (
<Col span={24}>
<span style={label}>Observações</span>
<Text type="secondary">{data.obs}</Text>
</Col>
)}
</Row>
</Card>
{/* Itens */}
{data.itens?.length > 0 && (
<div>
<Text style={{ ...label, marginBottom: 8 }}>
Itens do Pedido ({data.itens.length})
</Text>
{data.itens.map((item) => (
<div
key={item.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderRadius: 8,
background: '#F8FAFC',
border: '1px solid #EBF0F5',
marginBottom: 6,
}}
>
<Space direction="vertical" size={0}>
<Text style={{ fontSize: 12, color: '#64748B' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{Number(item.qtd)} un × {fmt(Number(item.precoUnitario))}
</Text>
</Space>
<Text strong className="tabular-nums">
{fmt(Number(item.total))}
</Text>
</div>
))}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #EBF0F5',
}}
>
<Text strong style={{ fontSize: 15, color: '#003B8E' }}>
Total: {fmt(data.total)}
</Text>
</div>
</div>
)}
{/* Histórico */}
{timelineItems.length > 0 && (
<div>
<Text style={{ ...label, marginBottom: 10 }}>Histórico</Text>
<Timeline items={timelineItems} />
</div>
)}
</Space>
)}
</Drawer>
);
}
// ─── MobileOrderCard ──────────────────────────────────────────────────────────
function MobileOrderCard({
order,
onView,
}: {
order: PedidoSummary;
onView: (id: string) => void;
}) {
const navigate = useNavigate();
const cfg = STATUS[order.situa];
return (
<Card
style={{
borderRadius: 10,
marginBottom: 10,
border: `1px solid ${cfg?.rowBg ?? '#EBF0F5'}`,
background: cfg?.rowBg ?? '#fff',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: '14px 16px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text strong style={{ fontSize: 15 }}>
{order.numPedSar}
</Text>
<OrderStatusBadge situa={order.situa} descr={order.statusDescr} />
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Cód. cliente {order.idCliente} · {fmtDate(order.dtPedido)}
</Text>
<Text strong style={{ fontSize: 16, color: '#003B8E' }}>
{fmt(order.total)}
</Text>
<div style={{ marginTop: 10, display: 'flex', gap: 8 }}>
<Button
size="small"
icon={<EyeOutlined />}
disabled={order.fonte === 'erp'}
onClick={() => onView(order.id)}
>
Ver
</Button>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() =>
void navigate({ to: '/pedidos/novo', search: { clientId: String(order.idCliente) } })
}
>
Duplicar
</Button>
</div>
</Card>
);
}
// ─── EmptyState ───────────────────────────────────────────────────────────────
function EmptyOrders({ onNew }: { onNew: () => void }) {
return (
<Empty
image={<ShoppingCartOutlined style={{ fontSize: 56, color: '#D9E2EC' }} />}
imageStyle={{ height: 64 }}
description={
<Space direction="vertical" size={4}>
<Text strong style={{ fontSize: 15 }}>
Nenhum pedido encontrado
</Text>
<Text type="secondary">Tente alterar os filtros ou crie um novo pedido.</Text>
</Space>
}
style={{ padding: '48px 0' }}
>
<Button type="primary" icon={<PlusOutlined />} onClick={onNew}>
Novo Pedido
</Button>
</Empty>
);
}
// ─── OrdersPage ───────────────────────────────────────────────────────────────
export function OrdersPage() {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const [search, setSearch] = useState('');
const [situaFilter, setSituaFilter] = useState<number | undefined>();
const [period, setPeriod] = useState('');
const [page, setPage] = useState(1);
const [drawerOrderId, setDrawerOrderId] = useState<string | null>(null);
const limit = 20;
const { from, to } = period ? periodRange(period) : {};
const { data, isLoading } = useOrderList({
numPedSar: search || undefined,
situa: situaFilter,
from,
to,
page,
limit,
});
const rows = data?.data ?? [];
const total = data?.total ?? 0;
function clearFilters() {
setSearch('');
setSituaFilter(undefined);
setPeriod('');
setPage(1);
}
// ── Tabela desktop ─────────────────────────────────────────────────────────
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº Pedido',
dataIndex: 'numPedSar',
width: 140,
render: (_: string, row: PedidoSummary) => {
const label = row.numero ? String(row.numero) : row.numPedSar;
return row.fonte === 'erp' ? (
<Text strong className="tabular-nums">
{label}
</Text>
) : (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Text strong className="tabular-nums" style={{ color: '#0057D9' }}>
{label}
</Text>
</Link>
);
},
},
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, row: PedidoSummary) => (
<Space direction="vertical" size={0}>
<Text style={{ fontWeight: 500 }}>Cód. {row.idCliente}</Text>
{row.obs && (
<Text type="secondary" style={{ fontSize: 11 }} ellipsis={{ tooltip: row.obs }}>
{row.obs.slice(0, 40)}
</Text>
)}
</Space>
),
},
{
title: 'Status',
dataIndex: 'situa',
width: 140,
render: (s: number, row: PedidoSummary) => (
<OrderStatusBadge situa={s} descr={row.statusDescr} />
),
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) => (
<Text strong className="tabular-nums">
{fmt(v)}
</Text>
),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 110,
render: (v: string) => <Text type="secondary">{fmtDate(v)}</Text>,
},
{
title: '',
key: 'actions',
width: 48,
render: (_: unknown, row: PedidoSummary) => (
<OrderActionsMenu order={row} onView={(id) => setDrawerOrderId(id)} />
),
},
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* ── Cabeçalho ───────────────────────────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 20,
}}
>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Pedidos
</Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Acompanhe seus pedidos, status de envio e histórico comercial.
</p>
</div>
{!isMobile && (
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{ borderRadius: 8, fontWeight: 600 }}
>
Novo Pedido
</Button>
)}
</div>
{/* ── Métricas ────────────────────────────────────────────────── */}
<OrdersMetrics data={rows} />
{/* ── Filtros ─────────────────────────────────────────────────── */}
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
marginBottom: 16,
}}
styles={{ body: { padding: '14px 20px' } }}
>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} sm={24} md={8}>
<input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Buscar por nº do pedido..."
style={{
width: '100%',
height: 32,
padding: '0 11px',
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 14,
outline: 'none',
color: '#1F2937',
boxSizing: 'border-box',
}}
/>
</Col>
<Col xs={12} sm={8} md={5}>
<Select
style={{ width: '100%' }}
placeholder="Status"
allowClear
value={situaFilter}
onChange={(v) => {
setSituaFilter(v);
setPage(1);
}}
options={[
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Aprovado' },
{ value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' },
]}
/>
</Col>
<Col xs={12} sm={8} md={5}>
<Select
style={{ width: '100%' }}
placeholder="Período"
allowClear
value={period || undefined}
onChange={(v) => {
setPeriod(v ?? '');
setPage(1);
}}
options={[
{ value: 'today', label: 'Hoje' },
{ value: '7d', label: 'Últimos 7 dias' },
{ value: '30d', label: 'Últimos 30 dias' },
]}
/>
</Col>
<Col xs={24} sm={8} md={4}>
<Button
style={{ width: '100%', borderRadius: 6 }}
onClick={clearFilters}
disabled={!search && !situaFilter && !period}
>
Limpar filtros
</Button>
</Col>
</Row>
</Card>
{/* ── Conteúdo principal ──────────────────────────────────────── */}
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
</div>
) : rows.length === 0 ? (
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<EmptyOrders onNew={() => void navigate({ to: '/pedidos/novo' })} />
</Card>
) : isMobile ? (
/* ── Mobile: cards ─────────────────────────────────────────── */
<div>
{rows.map((o) => (
<MobileOrderCard key={o.id} order={o} onView={(id) => setDrawerOrderId(id)} />
))}
<div
style={{ textAlign: 'center', padding: '8px 0 16px', color: '#64748B', fontSize: 13 }}
>
Mostrando {rows.length} de {total} pedidos
</div>
</div>
) : (
/* ── Desktop: tabela ────────────────────────────────────────── */
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: 0 } }}
>
<Table<PedidoSummary>
rowKey="id"
columns={columns}
dataSource={rows}
size="middle"
onRow={(row) => ({
onClick: () => {
if (row.fonte !== 'erp') setDrawerOrderId(row.id);
},
style: {
background: STATUS[row.situa]?.rowBg ?? '#fff',
cursor: row.fonte !== 'erp' ? 'pointer' : 'default',
},
})}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: false,
showTotal: (t, [s, e]) => `Mostrando ${s}${e} de ${t} pedidos`,
onChange: (p) => setPage(p),
style: { padding: '12px 24px' },
}}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
)}
{/* ── Drawer de detalhe ───────────────────────────────────────── */}
<OrderDetailDrawer id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
{/* FAB mobile */}
{isMobile && (
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
size="large"
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{
position: 'fixed',
bottom: 24,
right: 24,
width: 52,
height: 52,
fontSize: 22,
backgroundColor: '#389e0d',
borderColor: '#389e0d',
boxShadow: '0 4px 16px rgba(56,158,13,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
)}
<style>{`
.ant-table-row:hover td { background: inherit !important; filter: brightness(0.97); }
`}</style>
</div>
);
}