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:
155
apps/web/src/cockpits/rafael/ClientDetailPage.tsx
Normal file
155
apps/web/src/cockpits/rafael/ClientDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/cockpits/rafael/OrdersPage.tsx
Normal file
141
apps/web/src/cockpits/rafael/OrdersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/lib/queries/orders.ts
Normal file
56
apps/web/src/lib/queries/orders.ts
Normal 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[];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user