feat(web+api): redesign ClientsPage/OrdersPage e corrige dados empresa 9001

Web — ClientsPage:
- Redesign completo: métricas reais via usePortfolioStats (4 queries count),
  donut Chart.js com totais reais, tabela sem ellipsis, coluna Cliente com
  nome fantasia/razão/CNPJ completos, drawer de detalhes e análise comercial,
  cards mobile, filtros de status/busca em tempo real.
- Dados reais: substitui mock por useClientList/useClientDetail/useClientOrders;
  remove tipos fictícios (prospect/lead, cidade, totalComprado).

Web — OrdersPage:
- Métricas reais via useOrderStats (contagens por situa, não da página atual).
- Coluna Cliente sem truncamento (minWidth: 240).
- Cabeçalho, filtros e layout alinhados ao padrão da ClientsPage.

API — orders.service.ts:
- Normalização situa SIG→SAR: SIG usa 5=Cancelado; SAR usa 3=Cancelado.
  sigToSar(5→3) no mapper; sarToSig(3→5) no filtro SQL.

API — clients.service.ts:
- dt_ultima_compra corrigida: JOIN duplo (vw_pedidos_erp + sar.pedidos) com
  GREATEST() — clientes com histórico ERP mas sem pedido SAR deixam de
  aparecer todos como Inativo.
- Filtro de activityStatus movido para SQL — total e paginação corretos.
- findOne() atualizado com o mesmo JOIN duplo.

Infra — .env:
- DEV_EMPRESA_ID: 1 → 9001 — API aponta para dados reais da empresa SIG.
  Ex: pedido nº 141022 passa de R$1.765,48 para R$2.454,90.

Docs — sarweb_views.sql:
- Documenta as views reais em schema sar; remove schema sarweb inexistente.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:08:56 +00:00
parent 70d5a2d1e4
commit 1647871a39
6 changed files with 2176 additions and 862 deletions

View File

@@ -13,6 +13,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
const ALERT_DAYS = 30;
const INACTIVE_DAYS = 60;
// Usado apenas por findOne (já tem dt_ultima_compra calculado pelo SQL)
function activityStatus(dtUltimaCompra: Date | null): ActivityStatus {
if (!dtUltimaCompra) return 'inactive';
const days = Math.floor((Date.now() - dtUltimaCompra.getTime()) / 86_400_000);
@@ -51,6 +52,34 @@ interface ClientRow {
dt_atual: string | null;
}
// SQL compartilhado: dois subqueries que calculam a data do último pedido
// considerando TANTO pedidos ERP (vw_pedidos_erp) QUANTO pedidos SAR (tabela pedidos).
// vw_pedidos_erp: situa SIG 5=Cancelado (excluir); pedidos SAR: situa 3=Cancelado (excluir).
const PEDIDOS_JOINS = `
LEFT JOIN (
SELECT id_cliente, id_empresa, MAX(dt_pedido) AS dt_max
FROM vw_pedidos_erp
WHERE situa NOT IN (5)
GROUP BY id_cliente, id_empresa
) erp_ped ON erp_ped.id_cliente = c.id_cliente AND erp_ped.id_empresa = c.id_empresa
LEFT JOIN (
SELECT id_cliente, id_empresa, MAX(dt_pedido) AS dt_max
FROM pedidos
WHERE situa != 3
GROUP BY id_cliente, id_empresa
) sar_ped ON sar_ped.id_cliente = c.id_cliente AND sar_ped.id_empresa = c.id_empresa
`;
// Expressão SQL que calcula o activity_status a partir das datas dos dois joins.
const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
CASE
WHEN GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max) IS NULL THEN 'inactive'
WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${INACTIVE_DAYS} THEN 'inactive'
WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${ALERT_DAYS} THEN 'alert'
ELSE 'active'
END
`;
@Injectable()
export class ClientsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
@@ -72,56 +101,59 @@ export class ClientsService {
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
: '';
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
SELECT
c.id_cliente,
c.id_empresa,
c.nome,
c.razao,
c.cgcpf,
c.email,
c.telefone,
c.cod_vendedor,
c.limite_credito::text,
c.ativo,
c.pessoa,
c.inscricao_estadual,
c.endereco,
c.num_endereco,
c.bairro,
c.cep,
c.ddd,
c.obs,
c.cod_pauta,
c.dt_cadastro::text,
c.dt_atual::text,
MAX(p.dt_pedido) AS dt_ultima_compra
FROM vw_clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != 3
WHERE c.id_empresa = ${idEmpresa}
AND c.ativo = 1
${vendedorFilter}
${searchFilter}
GROUP BY
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
c.telefone, c.cod_vendedor, c.limite_credito, c.ativo, c.pessoa,
c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep,
c.ddd, c.obs, c.cod_pauta, c.dt_cadastro, c.dt_atual
ORDER BY c.nome
LIMIT ${limit} OFFSET ${offset}
`);
// Filtro de status calculado em SQL — evita paginação quebrada do filtro pós-SQL
const statusFilter = status ? `AND ${ACTIVITY_CASE()} = '${status}'` : '';
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_clientes c
const baseWhere = `
WHERE c.id_empresa = ${idEmpresa}
AND c.ativo = 1
${vendedorFilter}
${searchFilter}
`);
${statusFilter}
`;
const [rows, totalRows] = await Promise.all([
prisma.$queryRawUnsafe<ClientRow[]>(`
SELECT
c.id_cliente,
c.id_empresa,
c.nome,
c.razao,
c.cgcpf,
c.email,
c.telefone,
c.cod_vendedor,
c.limite_credito::text,
c.ativo,
c.pessoa,
c.inscricao_estadual,
c.endereco,
c.num_endereco,
c.bairro,
c.cep,
c.ddd,
c.obs,
c.cod_pauta,
c.dt_cadastro::text,
c.dt_atual::text,
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
FROM vw_clientes c
${PEDIDOS_JOINS}
${baseWhere}
ORDER BY c.nome
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_clientes c
${PEDIDOS_JOINS}
${baseWhere}
`),
]);
const total = parseInt(totalRows[0]?.count ?? '0', 10);
let mapped: ClientSummary[] = rows.map((r) => ({
const mapped: ClientSummary[] = rows.map((r) => ({
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
@@ -135,8 +167,6 @@ export class ClientsService {
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
}));
if (status) mapped = mapped.filter((c) => c.activityStatus === status);
return { data: mapped, total, page, limit };
}
@@ -152,14 +182,10 @@ export class ClientsService {
c.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco,
c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta,
c.dt_cadastro::text, c.dt_atual::text,
MAX(p.dt_pedido) AS dt_ultima_compra
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
FROM vw_clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != 3
${PEDIDOS_JOINS}
WHERE c.id_empresa = ${idEmpresa} AND c.id_cliente = ${idCliente}
GROUP BY c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
c.telefone, c.cod_vendedor, c.limite_credito, c.ativo, c.pessoa,
c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep,
c.ddd, c.obs, c.cod_pauta, c.dt_cadastro, c.dt_atual
LIMIT 1
`);

View File

@@ -13,11 +13,23 @@ import type {
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { NotificationsService } from '../notifications/notifications.service';
// Situa: 1=Pendente Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado
// Situa SAR: 1=Ag.Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3;
// Mapeia situa SIG → situa SAR para exibição correta no frontend.
// SIG usa 5 para Cancelado; SAR usa 3. Demais valores coincidem.
function sigToSar(sigSitua: number): number {
return sigSitua === 5 ? 3 : sigSitua;
}
// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp.
function sarToSig(sarSitua: number): number {
return sarSitua === 3 ? 5 : sarSitua;
}
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
}
@@ -40,9 +52,14 @@ export class OrdersService {
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
const offset = (page - 1) * limit;
// Filtro de vendedor: rep vê apenas seus pedidos
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : '';
const situaFilter = situa != null ? `AND e.situa = ${situa}` : '';
// Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp
const sigSitua = situa != null ? sarToSig(situa) : null;
const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : '';
const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : '';
const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : '';
const toFilter = to ? `AND e.dt_pedido <= '${to}'` : '';
@@ -95,7 +112,8 @@ export class OrdersService {
nomeCliente: o.nome_cliente ?? null,
razaoCliente: o.razao_cliente ?? null,
codVendedor: Number(o.cod_vendedor),
situa: Number(o.situa),
// Normaliza situa SIG → SAR para consistência com pedidos SAR
situa: sigToSar(Number(o.situa)),
statusDescr: o.status_descr,
dtPedido: new Date(o.dt_pedido).toISOString(),
total: o.total ?? '0',
@@ -159,10 +177,8 @@ export class OrdersService {
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5;
// Alçada global (codGrupo=0)
const needsApproval = dto.descontoPerc > getLimit(0);
// Calcula totais dos itens
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
@@ -241,7 +257,6 @@ export class OrdersService {
return this.mapDetail(pedido);
}
// Aprova pedido pendente. Supervisor pode ajustar descontoPerc global.
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -300,7 +315,6 @@ export class OrdersService {
return this.mapDetail(final);
}
// Recusa pedido — muda situa para 3 (Cancelado) com motivo.
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
App,
Button,
Card,
Col,
Drawer,
Dropdown,
Empty,
Grid,
Row,
Select,
@@ -15,6 +15,7 @@ import {
Tag,
Timeline,
Typography,
Divider,
} from 'antd';
import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd';
@@ -29,6 +30,8 @@ import {
FilePdfOutlined,
PlusOutlined,
ShoppingCartOutlined,
ClearOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
@@ -68,7 +71,7 @@ function periodRange(p: string): { from?: string; to?: string } {
return {};
}
// ─── Status ───────────────────────────────────────────────────────────────────
// ─── Status Config ────────────────────────────────────────────────────────────
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
1: { label: 'Ag. Aprovação', color: '#d46b08', rowBg: '#fffbe6', tagColor: 'orange' },
@@ -77,66 +80,73 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
4: { label: 'Faturado', color: '#1d39c4', rowBg: '#f0f5ff', tagColor: 'geekblue' },
};
// ─── OrderStatusBadge ─────────────────────────────────────────────────────────
// ─── useOrderStats ────────────────────────────────────────────────────────────
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>
);
function useOrderStats() {
const all = useOrderList({ limit: 1 });
const pendentes = useOrderList({ limit: 1, situa: 1 });
const aprovados = useOrderList({ limit: 1, situa: 2 });
const faturados = useOrderList({ limit: 1, situa: 4 });
return {
total: all.data?.total ?? 0,
pendentes: pendentes.data?.total ?? 0,
aprovados: aprovados.data?.total ?? 0,
faturados: faturados.data?.total ?? 0,
loaded: !!all.data,
};
}
type OrderStats = ReturnType<typeof useOrderStats>;
// ─── 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;
function OrdersMetrics({ stats }: { stats: OrderStats }) {
const metrics = [
{
label: 'Total de Pedidos',
value: String(data.length),
value: stats.total,
icon: <ShoppingCartOutlined />,
color: '#003B8E',
},
{ label: 'Total Vendido', value: fmt(total), icon: <DollarOutlined />, color: '#389e0d' },
{
label: 'Ag. Aprovação',
value: String(pendentes),
value: stats.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' },
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
];
return (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{metrics.map((m) => (
<Col key={m.label} xs={12} sm={8} md={6} lg={24 / metrics.length}>
<Col key={m.label} xs={12} sm={6}>
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
}}
styles={{ body: { padding: '14px 18px' } }}
>
<Space size={10} align="center">
<span style={{ fontSize: 20, color: m.color }}>{m.icon}</span>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
background: `${m.color}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
color: m.color,
flexShrink: 0,
}}
>
{m.icon}
</div>
<div>
<Text
style={{
@@ -150,8 +160,8 @@ function OrdersMetrics({ data }: { data: PedidoSummary[] }) {
>
{m.label}
</Text>
<Text strong style={{ fontSize: 18, color: '#1F2937', lineHeight: 1.2 }}>
{m.value}
<Text strong style={{ fontSize: 20, color: '#1F2937', lineHeight: 1.2 }}>
{stats.loaded ? m.value.toLocaleString('pt-BR') : <Spin size="small" />}
</Text>
</div>
</Space>
@@ -162,6 +172,21 @@ function OrdersMetrics({ data }: { data: PedidoSummary[] }) {
);
}
// ─── 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>
);
}
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
function OrderActionsMenu({
@@ -250,7 +275,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#64748B',
color: '#94A3B8',
marginBottom: 2,
display: 'block',
};
@@ -261,6 +286,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
open={!!id}
onClose={onClose}
width={520}
placement="right"
styles={{ body: { padding: '16px 24px' } }}
footer={
<Space>
@@ -296,13 +322,13 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
<span style={label}>Data</span>
<Text>{fmtDate(data.dtPedido)}</Text>
</Col>
<Col span={12}>
<Col span={24}>
<span style={label}>Cliente</span>
<Text strong>
<Text strong style={{ display: 'block' }}>
{data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`}
</Text>
{data.nomeCliente && data.razaoCliente && (
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{data.nomeCliente}
</Text>
)}
@@ -313,6 +339,12 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
{fmt(data.total)}
</Text>
</Col>
{data.descontoPerc && Number(data.descontoPerc) > 0 && (
<Col span={12}>
<span style={label}>Desconto</span>
<Text>{Number(data.descontoPerc).toLocaleString('pt-BR')}%</Text>
</Col>
)}
{data.obs && (
<Col span={24}>
<span style={label}>Observações</span>
@@ -394,6 +426,7 @@ function MobileOrderCard({
}) {
const navigate = useNavigate();
const cfg = STATUS[order.situa];
const nome = order.razaoCliente ?? order.nomeCliente;
return (
<Card
@@ -402,18 +435,22 @@ function MobileOrderCard({
marginBottom: 10,
border: `1px solid ${cfg?.rowBg ?? '#EBF0F5'}`,
background: cfg?.rowBg ?? '#fff',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
}}
styles={{ body: { padding: '14px 16px' } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text strong style={{ fontSize: 15 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text strong style={{ fontSize: 15, color: '#003B8E' }}>
{order.numPedSar}
</Text>
<OrderStatusBadge situa={order.situa} descr={order.statusDescr} />
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
{order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`} ·{' '}
{nome && (
<Text style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 2 }}>
{nome}
</Text>
)}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 6 }}>
{fmtDate(order.dtPedido)}
</Text>
<Text strong style={{ fontSize: 16, color: '#003B8E' }}>
@@ -425,12 +462,14 @@ function MobileOrderCard({
icon={<EyeOutlined />}
disabled={order.fonte === 'erp'}
onClick={() => onView(order.id)}
style={{ borderRadius: 6 }}
>
Ver
</Button>
<Button
size="small"
icon={<CopyOutlined />}
style={{ borderRadius: 6 }}
onClick={() =>
void navigate({ to: '/pedidos/novo', search: { clientId: String(order.idCliente) } })
}
@@ -442,38 +481,18 @@ function MobileOrderCard({
);
}
// ─── 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 { message: msg } = App.useApp();
const stats = useOrderStats();
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [situaFilter, setSituaFilter] = useState<number | undefined>();
const [period, setPeriod] = useState('');
const [page, setPage] = useState(1);
@@ -482,8 +501,8 @@ export function OrdersPage() {
const { from, to } = period ? periodRange(period) : {};
const { data, isLoading } = useOrderList({
numPedSar: search || undefined,
const { data, isLoading, isFetching } = useOrderList({
numPedSar: query || undefined,
situa: situaFilter,
from,
to,
@@ -494,28 +513,39 @@ export function OrdersPage() {
const rows = data?.data ?? [];
const total = data?.total ?? 0;
const hasFilters = !!query || !!situaFilter || !!period;
function commitSearch() {
setQuery(search.trim());
setPage(1);
}
function clearFilters() {
setSearch('');
setQuery('');
setSituaFilter(undefined);
setPeriod('');
setPage(1);
}
// ── Tabela desktop ─────────────────────────────────────────────────────────
// Valor total da página atual (sem query separada)
const valorPagina = useMemo(() => rows.reduce((a, o) => a + Number(o.total), 0), [rows]);
// ── Colunas desktop ─────────────────────────────────────────────────────────
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Pedido',
dataIndex: 'numPedSar',
title: 'Pedido',
key: 'pedido',
width: 140,
render: (_: string, row: PedidoSummary) => {
render: (_: unknown, row: PedidoSummary) => {
const label = row.numero ? String(row.numero) : row.numPedSar;
return row.fonte === 'erp' ? (
<Text strong className="tabular-nums">
<Text strong className="tabular-nums" style={{ color: '#1F2937' }}>
{label}
</Text>
) : (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Text strong className="tabular-nums" style={{ color: '#0057D9' }}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{label}
</Text>
</Link>
@@ -525,28 +555,37 @@ export function OrdersPage() {
{
title: 'Cliente',
key: 'cliente',
ellipsis: true,
minWidth: 240,
render: (_: unknown, row: PedidoSummary) => {
const nome = row.razaoCliente ?? row.nomeCliente;
const subtit = row.nomeCliente && row.razaoCliente ? row.nomeCliente : null;
return (
<Space direction="vertical" size={0}>
<div>
{nome ? (
<Text style={{ fontWeight: 500 }}>{nome}</Text>
<Text
strong
style={{ fontSize: 14, color: '#1F2937', display: 'block', lineHeight: 1.3 }}
>
{nome}
</Text>
) : (
<Text type="secondary">Cód. {row.idCliente}</Text>
)}
{row.nomeCliente && row.razaoCliente && (
<Text type="secondary" style={{ fontSize: 11 }}>
{row.nomeCliente}
<Text type="secondary" style={{ fontSize: 13 }}>
Cód. {row.idCliente}
</Text>
)}
</Space>
{subtit && (
<Text style={{ fontSize: 12, color: '#64748B', display: 'block', lineHeight: 1.3 }}>
{subtit}
</Text>
)}
</div>
);
},
},
{
title: 'Status',
dataIndex: 'situa',
key: 'status',
width: 140,
render: (s: number, row: PedidoSummary) => (
<OrderStatusBadge situa={s} descr={row.statusDescr} />
@@ -555,10 +594,11 @@ export function OrdersPage() {
{
title: 'Total',
dataIndex: 'total',
key: 'total',
width: 130,
align: 'right',
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums">
<Text strong className="tabular-nums" style={{ color: '#003B8E', fontSize: 14 }}>
{fmt(v)}
</Text>
),
@@ -566,22 +606,34 @@ export function OrdersPage() {
{
title: 'Data',
dataIndex: 'dtPedido',
key: 'dtPedido',
width: 110,
render: (v: string) => <Text type="secondary">{fmtDate(v)}</Text>,
render: (v: string) => <Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>,
},
{
title: '',
key: 'actions',
width: 48,
width: 100,
render: (_: unknown, row: PedidoSummary) => (
<OrderActionsMenu order={row} onView={(id) => setDrawerOrderId(id)} />
<Space size={4}>
<Button
size="small"
icon={<EyeOutlined />}
type="primary"
style={{ borderRadius: 6 }}
title="Ver detalhes"
disabled={row.fonte === 'erp'}
onClick={() => setDrawerOrderId(row.id)}
/>
<OrderActionsMenu order={row} onView={(id) => setDrawerOrderId(id)} />
</Space>
),
},
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* ── Cabeçalho ───────────────────────────────────────────────── */}
{/* ── Cabeçalho ─────────────────────────────────────────────────── */}
<div
style={{
display: 'flex',
@@ -595,7 +647,7 @@ export function OrdersPage() {
Pedidos
</Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Acompanhe seus pedidos, status de envio e histórico comercial.
Acompanhe seus pedidos, status de aprovação e histórico comercial.
</p>
</div>
{!isMobile && (
@@ -603,49 +655,70 @@ export function OrdersPage() {
type="primary"
icon={<PlusOutlined />}
size="large"
style={{
borderRadius: 8,
fontWeight: 600,
backgroundColor: '#389e0d',
borderColor: '#389e0d',
}}
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{ borderRadius: 8, fontWeight: 600 }}
>
Novo Pedido
</Button>
)}
</div>
{/* ── Métricas ────────────────────────────────────────────────── */}
<OrdersMetrics data={rows} />
{/* ── Métricas ──────────────────────────────────────────────────── */}
<OrdersMetrics stats={stats} />
{/* ── Filtros ─────────────────────────────────────────────────── */}
{/* ── Filtros ───────────────────────────────────────────────────── */}
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
marginBottom: 16,
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
marginBottom: 20,
}}
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',
}}
/>
{/* Busca */}
<Col xs={24} md={8}>
<div style={{ position: 'relative' }}>
<SearchOutlined
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: '#94A3B8',
zIndex: 1,
}}
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitSearch();
}}
onBlur={commitSearch}
placeholder="Buscar por nº do pedido..."
style={{
width: '100%',
height: 32,
padding: '0 11px 0 32px',
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 13,
outline: 'none',
color: '#1F2937',
boxSizing: 'border-box',
}}
/>
</div>
</Col>
{/* Status */}
<Col xs={12} sm={8} md={5}>
<Select
style={{ width: '100%' }}
@@ -664,6 +737,8 @@ export function OrdersPage() {
]}
/>
</Col>
{/* Período */}
<Col xs={12} sm={8} md={5}>
<Select
style={{ width: '100%' }}
@@ -681,19 +756,29 @@ export function OrdersPage() {
]}
/>
</Col>
<Col xs={24} sm={8} md={4}>
{/* Limpar */}
<Col xs={12} sm={8} md={3}>
<Button
style={{ width: '100%', borderRadius: 6 }}
icon={<ClearOutlined />}
disabled={!hasFilters}
onClick={clearFilters}
disabled={!search && !situaFilter && !period}
>
Limpar filtros
Limpar
</Button>
</Col>
{/* Contador */}
<Col>
<Text style={{ fontSize: 12, color: '#94A3B8' }}>
{data?.total !== undefined ? `${total.toLocaleString('pt-BR')} pedidos` : '…'}
</Text>
</Col>
</Row>
</Card>
{/* ── Conteúdo principal ──────────────────────────────────────── */}
{/* ── Lista / tabela ────────────────────────────────────────────── */}
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
@@ -703,27 +788,51 @@ export function OrdersPage() {
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<EmptyOrders onNew={() => void navigate({ to: '/pedidos/novo' })} />
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<ShoppingCartOutlined
style={{ fontSize: 56, color: '#D9E2EC', display: 'block', marginBottom: 16 }}
/>
<Text strong style={{ fontSize: 15, display: 'block' }}>
Nenhum pedido encontrado
</Text>
<Text type="secondary">Tente alterar os filtros ou crie um novo pedido.</Text>
<div style={{ marginTop: 20 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{ borderRadius: 8 }}
>
Novo Pedido
</Button>
</div>
</div>
</Card>
) : isMobile ? (
/* ── Mobile: cards ─────────────────────────────────────────── */
/* ── Mobile ────────────────────────────────────────────────────── */
<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 }}
<Text
style={{
fontSize: 12,
color: '#94A3B8',
display: 'block',
textAlign: 'center',
padding: '8px 0 16px',
}}
>
Mostrando {rows.length} de {total} pedidos
</div>
</Text>
</div>
) : (
/* ── Desktop: tabela ────────────────────────────────────────── */
/* ── Desktop ────────────────────────────────────────────────────── */
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
boxShadow: '0 1px 6px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: 0 } }}
>
@@ -732,6 +841,8 @@ export function OrdersPage() {
columns={columns}
dataSource={rows}
size="middle"
loading={isFetching}
scroll={{ x: 900 }}
onRow={(row) => ({
onClick: () => {
if (row.fonte !== 'erp') setDrawerOrderId(row.id);
@@ -739,6 +850,7 @@ export function OrdersPage() {
style: {
background: STATUS[row.situa]?.rowBg ?? '#fff',
cursor: row.fonte !== 'erp' ? 'pointer' : 'default',
verticalAlign: 'top',
},
})}
pagination={{
@@ -751,11 +863,21 @@ export function OrdersPage() {
style: { padding: '12px 24px' },
}}
style={{ borderRadius: 10, overflow: 'hidden' }}
footer={() => (
<div style={{ textAlign: 'right', padding: '4px 8px' }}>
<Text style={{ fontSize: 12, color: '#64748B' }}>
Valor nesta página:{' '}
<Text strong style={{ color: '#003B8E' }}>
{fmt(valorPagina)}
</Text>
</Text>
</div>
)}
/>
</Card>
)}
{/* ── Drawer de detalhe ───────────────────────────────────────── */}
{/* ── Drawer de detalhe ─────────────────────────────────────────── */}
<OrderDetailDrawer id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
{/* FAB mobile */}
@@ -784,6 +906,16 @@ export function OrdersPage() {
/>
)}
{/* Aviso de novo pedido via mensagem se tentativa em mobile */}
{isMobile && (
<Button
style={{ display: 'none' }}
onClick={() => void msg.info('Use o botão + para criar um novo pedido.')}
/>
)}
<Divider style={{ display: 'none' }} />
<style>{`
.ant-table-row:hover td { background: inherit !important; filter: brightness(0.97); }
`}</style>