feat(c3): consulta de pedidos — schema, api, web (OrdersModule + ClientDetailPage)

- Prisma: Order, OrderItem, OrderStatusHistory + migration
- Seed: 17 pedidos em 7 clientes com itens, histórico e desnorm de clientes
- @sar/api-interface: contratos Zod (OrderSummary, OrderDetail, OrderListQuery, etc.)
- API: GET /orders, GET /orders/:id, GET /clients/:id/orders (últimos 10)
- Web: OrdersPage (lista + filtro status/número + pending_approval highlighted)
- Web: ClientDetailPage (ficha completa + últimos 10 pedidos)
- Web: /pedidos e /pedidos/$id adicionados ao router; ClientDetailPage substitui placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:31:18 +00:00
parent 14c8350216
commit c36451dd33
15 changed files with 1494 additions and 71 deletions

View File

@@ -0,0 +1,155 @@
import { Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link, useParams } from '@tanstack/react-router';
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
const { Title } = Typography;
const FINANCIAL_COLOR: Record<string, string> = {
regular: 'success',
attention: 'warning',
blocked: 'error',
};
const FINANCIAL_LABEL: Record<string, string> = {
regular: 'Regular',
attention: 'Atenção',
blocked: 'Bloqueado',
};
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 STATUS_LABEL: Record<OrderStatus, string> = {
budget: 'Orçamento',
pending_approval: 'Ag. Aprovação',
approved: 'Aprovado',
invoiced: 'Faturado',
cancelled: 'Cancelado',
};
const STATUS_COLOR: Record<OrderStatus, string> = {
budget: 'default',
pending_approval: 'warning',
approved: 'processing',
invoiced: 'success',
cancelled: 'error',
};
const orderColumns: TableColumnsType<OrderSummary> = [
{
title: 'Nº',
dataIndex: 'number',
width: 120,
render: (num: string, row: OrderSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Status',
dataIndex: 'status',
width: 140,
render: (s: OrderStatus) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>,
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Emitido em',
dataIndex: 'issuedAt',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (clientError || !client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
const addr = client.address;
return (
<div style={{ padding: 24 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
{client.tradeName ?? client.name}
</Title>
<Tag color={FINANCIAL_COLOR[client.financialStatus]}>
{FINANCIAL_LABEL[client.financialStatus]}
</Tag>
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
{ACTIVITY_LABEL[client.activityStatus]}
</Tag>
</Space>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="Razão Social">{client.name}</Descriptions.Item>
<Descriptions.Item label="CNPJ">{client.taxId}</Descriptions.Item>
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Telefone">{client.phone ?? '—'}</Descriptions.Item>
{addr && (
<Descriptions.Item label="Endereço" span={2}>
{addr.street}, {addr.number}
{addr.complement ? `, ${addr.complement}` : ''} {addr.district}, {addr.city}/
{addr.state} CEP {addr.zip}
</Descriptions.Item>
)}
<Descriptions.Item label="Limite de Crédito">
{client.creditLimit
? Number(client.creditLimit).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})
: '—'}
</Descriptions.Item>
<Descriptions.Item label="Pedidos em Aberto">{client.openOrdersCount}</Descriptions.Item>
<Descriptions.Item label="Último Pedido">
{client.lastOrderAt ? new Date(client.lastOrderAt).toLocaleDateString('pt-BR') : '—'}
</Descriptions.Item>
<Descriptions.Item label="Valor Último Pedido">
{client.lastOrderValue
? Number(client.lastOrderValue).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})
: '—'}
</Descriptions.Item>
{client.erpCode && (
<Descriptions.Item label="Código ERP">{client.erpCode}</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="left">Últimos 10 Pedidos</Divider>
<Table<OrderSummary>
rowKey="id"
columns={orderColumns}
dataSource={orders ?? []}
loading={ordersLoading}
pagination={false}
size="small"
rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
</div>
);
}

View File

@@ -0,0 +1,141 @@
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 { OrderSummary, OrderStatus } from '@sar/api-interface';
import { useOrderList } from '../../lib/queries/orders';
const { Title } = Typography;
const { Search } = Input;
const STATUS_COLOR: Record<OrderStatus, string> = {
budget: 'default',
pending_approval: 'warning',
approved: 'processing',
invoiced: 'success',
cancelled: 'error',
};
const STATUS_LABEL: Record<OrderStatus, string> = {
budget: 'Orçamento',
pending_approval: 'Ag. Aprovação',
approved: 'Aprovado',
invoiced: 'Faturado',
cancelled: 'Cancelado',
};
const columns: TableColumnsType<OrderSummary> = [
{
title: 'Nº',
dataIndex: 'number',
width: 120,
render: (num: string, row: OrderSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Cliente',
dataIndex: 'clientName',
ellipsis: true,
},
{
title: 'Status',
dataIndex: 'status',
width: 150,
render: (s: OrderStatus) => (
<Badge
status={STATUS_COLOR[s] as 'default' | 'warning' | 'processing' | 'success' | 'error'}
text={<Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>}
/>
),
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Emitido em',
dataIndex: 'issuedAt',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function OrdersPage() {
const [numberFilter, setNumberFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data, isLoading } = useOrderList({
number: numberFilter || undefined,
status: statusFilter,
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..."
allowClear
style={{ width: 220 }}
onSearch={(v) => {
setNumberFilter(v);
setPage(1);
}}
onChange={(e) => {
if (!e.target.value) {
setNumberFilter('');
setPage(1);
}
}}
/>
<Select
placeholder="Status"
allowClear
style={{ width: 160 }}
onChange={(v) => {
setStatusFilter(v as OrderStatus | undefined);
setPage(1);
}}
options={[
{ value: 'budget', label: 'Orçamento' },
{ value: 'pending_approval', label: 'Ag. Aprovação' },
{ value: 'approved', label: 'Aprovado' },
{ value: 'invoiced', label: 'Faturado' },
{ value: 'cancelled', label: 'Cancelado' },
]}
/>
</Space>
<Table<OrderSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (row.status === 'pending_approval' ? '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,56 @@
import { useQuery } from '@tanstack/react-query';
import {
OrderListResponseSchema,
OrderDetailSchema,
type OrderListQuery,
type OrderListResponse,
type OrderDetail,
type OrderSummary,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useOrderList(params: Partial<OrderListQuery> = {}) {
const search = new URLSearchParams();
if (params.clientId) search.set('clientId', params.clientId);
if (params.status) search.set('status', params.status);
if (params.number) search.set('number', params.number);
if (params.from) search.set('from', params.from);
if (params.to) search.set('to', params.to);
if (params.page) search.set('page', String(params.page));
if (params.limit) search.set('limit', String(params.limit));
const qs = search.toString();
return useQuery<OrderListResponse>({
queryKey: ['orders', params],
queryFn: async () => {
const res = await apiFetch(`/orders${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error(`orders list error ${res.status}`);
return OrderListResponseSchema.parse(await res.json());
},
});
}
export function useOrderDetail(id: string | undefined) {
return useQuery<OrderDetail>({
queryKey: ['orders', id],
enabled: !!id,
queryFn: async () => {
const res = await apiFetch(`/orders/${id}`);
if (!res.ok) throw new Error(`order detail error ${res.status}`);
return OrderDetailSchema.parse(await res.json());
},
});
}
export function useClientOrders(clientId: string | undefined) {
return useQuery<OrderSummary[]>({
queryKey: ['clients', clientId, 'orders'],
enabled: !!clientId,
queryFn: async () => {
const res = await apiFetch(`/clients/${clientId}/orders`);
if (!res.ok) throw new Error(`client orders error ${res.status}`);
const data = await res.json();
return data as OrderSummary[];
},
});
}

View File

@@ -2,6 +2,8 @@ import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/re
import { AppShell } from '../components/layout/AppShell';
import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
const rootRoute = createRootRoute({
component: () => (
@@ -29,15 +31,26 @@ const clientesRoute = createRoute({
component: ClientsPage,
});
// Placeholder detail route — ClientDetailPage virá em próxima iteração de C2
const clienteDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/$id',
component: ClientDetailPage,
});
const pedidosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos',
component: OrdersPage,
});
const pedidoDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/$id',
component: () => {
const { id } = clienteDetailRoute.useParams();
const { id } = pedidoDetailRoute.useParams();
return (
<div style={{ padding: 24 }}>
<p>Ficha do cliente {id} em construção</p>
<p>Detalhe do pedido {id} em construção</p>
</div>
);
},
@@ -48,6 +61,8 @@ const routeTree = rootRoute.addChildren([
rafaelRoute,
clientesRoute,
clienteDetailRoute,
pedidosRoute,
pedidoDetailRoute,
]);
export const router = createRouter({