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>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,115 +1,643 @@
|
||||
import { useState } from 'react';
|
||||
import { Table, Tag, Input, Select, Space, Typography, Badge } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Grid,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
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 } from '../../lib/queries/orders';
|
||||
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Search } = Input;
|
||||
const { Title, Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const SITUA_COLOR: Record<number, string> = {
|
||||
1: 'warning',
|
||||
2: 'processing',
|
||||
3: 'error',
|
||||
4: 'success',
|
||||
// ─── 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º',
|
||||
dataIndex: 'numero',
|
||||
width: 120,
|
||||
render: (_: number, row: PedidoSummary) => {
|
||||
const label = row.numero ? String(row.numero) : row.numPedSar || row.id;
|
||||
title: 'Nº Pedido',
|
||||
dataIndex: 'numPedSar',
|
||||
width: 140,
|
||||
render: (_: string, row: PedidoSummary) => {
|
||||
const label = row.numero ? String(row.numero) : row.numPedSar;
|
||||
return row.fonte === 'erp' ? (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{label}</span>
|
||||
<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: 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>}
|
||||
/>
|
||||
);
|
||||
},
|
||||
width: 140,
|
||||
render: (s: number, row: PedidoSummary) => (
|
||||
<OrderStatusBadge situa={s} descr={row.statusDescr} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (v: string) =>
|
||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||
render: (v: string) => (
|
||||
<Text strong className="tabular-nums">
|
||||
{fmt(v)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Data',
|
||||
dataIndex: 'dtPedido',
|
||||
width: 130,
|
||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||
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)} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 }}>
|
||||
<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>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Search
|
||||
placeholder="Buscar por número (SAR-NNNNN)..."
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
onSearch={(v) => {
|
||||
setNumFilter(v);
|
||||
{/* ── 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);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setNumFilter('');
|
||||
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
|
||||
style={{ width: 160 }}
|
||||
value={situaFilter}
|
||||
onChange={(v) => {
|
||||
setSituaFilter(v as number | undefined);
|
||||
setSituaFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
@@ -119,24 +647,130 @@ export function OrdersPage() {
|
||||
{ value: 4, label: 'Faturado' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</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={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||
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: data?.total ?? 0,
|
||||
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>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
||||
<style>{`
|
||||
.ant-table-row:hover td { background: inherit !important; filter: brightness(0.97); }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Flex } from 'antd';
|
||||
import { Button, Flex, Tooltip } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Topbar } from './Topbar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
@@ -15,6 +17,7 @@ interface AppShellProps {
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
||||
@@ -32,6 +35,31 @@ export function AppShell({ children }: AppShellProps) {
|
||||
{children}
|
||||
</main>
|
||||
</Flex>
|
||||
|
||||
{/* FAB — Novo Pedido */}
|
||||
<Tooltip title="Novo Pedido" placement="left">
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 32,
|
||||
right: 32,
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Avatar, Badge, Button, Dropdown, Flex, Input, Typography } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBell,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
faBars,
|
||||
faRightFromBracket,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { brandTokens } from '../../lib/theme';
|
||||
import { FoundationStatus } from './FoundationStatus';
|
||||
import { usePendingCount } from '../../lib/queries/notifications';
|
||||
@@ -27,6 +29,7 @@ function logout() {
|
||||
}
|
||||
|
||||
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: pendingData } = usePendingCount();
|
||||
const pendingCount = pendingData?.count ?? 0;
|
||||
const { data: user } = useCurrentUser();
|
||||
@@ -131,8 +134,16 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Lado direito: status fundação + notificações + perfil */}
|
||||
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
|
||||
<Flex align="center" gap={16}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
||||
style={{ borderRadius: 8, fontWeight: 'var(--font-weight-semibold)' }}
|
||||
>
|
||||
Novo Pedido
|
||||
</Button>
|
||||
<FoundationStatus />
|
||||
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user