feat(c4): lançamento de pedido — catálogo, alçada por linha, POST /orders
- Prisma: Product + RepDiscountLimit + productCategory em OrderItem + migration - Seed: 28 produtos (5 categorias) + alçadas user-001 (default 10%, bebidas 8%, perecíveis 5%) - @sar/api-interface: ProductSummarySchema, ProductDetailSchema, ProductSyncRequestSchema, CreateOrderSchema - API: CatalogModule (GET /catalog, GET /catalog/:id, POST /catalog/sync) - API: POST /orders — valida alçada por linha/produto (OQ-2), idempotency-key (FR-4.3), desnorm cliente - Web: NewOrderPage (3 steps: catálogo → desconto/obs → confirmação) - Web: botão Novo Pedido na ClientDetailPage (desabilitado se financialStatus=blocked) - Web: rota /pedidos/novo com search param clientId Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
||||
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { Link, useNavigate, 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';
|
||||
@@ -77,6 +77,7 @@ const orderColumns: TableColumnsType<OrderSummary> = [
|
||||
|
||||
export function ClientDetailPage() {
|
||||
const { id } = useParams({ from: '/clientes/$id' });
|
||||
const navigate = useNavigate();
|
||||
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
|
||||
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
|
||||
|
||||
@@ -88,7 +89,7 @@ export function ClientDetailPage() {
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Space align="center" style={{ marginBottom: 16 }}>
|
||||
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||
<Link to="/clientes">← Clientes</Link>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
{client.tradeName ?? client.name}
|
||||
@@ -99,6 +100,13 @@ export function ClientDetailPage() {
|
||||
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
||||
{ACTIVITY_LABEL[client.activityStatus]}
|
||||
</Tag>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
||||
disabled={client.financialStatus === 'blocked'}
|
||||
>
|
||||
Novo Pedido
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
|
||||
436
apps/web/src/cockpits/rafael/NewOrderPage.tsx
Normal file
436
apps/web/src/cockpits/rafael/NewOrderPage.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
InputNumber,
|
||||
Space,
|
||||
Spin,
|
||||
Steps,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Input,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import type { CreateOrder, CreateOrderItem, ProductSummary } from '@sar/api-interface';
|
||||
import { useClientDetail } from '../../lib/queries/clients';
|
||||
import { useCatalog } from '../../lib/queries/catalog';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
type CartItem = CreateOrderItem & { key: string };
|
||||
|
||||
function calcItemTotal(qty: number, price: number, disc: number): number {
|
||||
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
// ─── Step 1 — Selecionar Produtos ────────────────────────────────────────────
|
||||
|
||||
function ProductStep({
|
||||
cart,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onQtyChange,
|
||||
onDiscChange,
|
||||
}: {
|
||||
cart: CartItem[];
|
||||
onAdd: (p: ProductSummary) => void;
|
||||
onRemove: (key: string) => void;
|
||||
onQtyChange: (key: string, qty: number) => void;
|
||||
onDiscChange: (key: string, disc: number) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState('');
|
||||
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
|
||||
|
||||
const cartKeys = new Set(cart.map((c) => c.productCode));
|
||||
|
||||
const catalogColumns: TableColumnsType<ProductSummary> = [
|
||||
{ title: 'Código', dataIndex: 'code', width: 100 },
|
||||
{ title: 'Produto', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: 'Categoria',
|
||||
dataIndex: 'category',
|
||||
width: 110,
|
||||
render: (v: string) => <Tag>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Preço',
|
||||
dataIndex: 'unitPrice',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (v: string) => fmt(Number(v)),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 80,
|
||||
render: (_: unknown, row: ProductSummary) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={cartKeys.has(row.code)}
|
||||
onClick={() => onAdd(row)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cartColumns: TableColumnsType<CartItem> = [
|
||||
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
|
||||
{
|
||||
title: 'Qtd',
|
||||
dataIndex: 'quantity',
|
||||
width: 100,
|
||||
render: (v: number, row: CartItem) => (
|
||||
<InputNumber
|
||||
min={0.001}
|
||||
step={1}
|
||||
value={v}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
onChange={(n) => onQtyChange(row.key, n ?? 1)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Desc %',
|
||||
dataIndex: 'discountPct',
|
||||
width: 100,
|
||||
render: (v: number, row: CartItem) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={v}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
onChange={(n) => onDiscChange(row.key, n ?? 0)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Subtotal',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (_: unknown, row: CartItem) =>
|
||||
fmt(calcItemTotal(row.quantity, row.unitPrice, row.discountPct)),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 40,
|
||||
render: (_: unknown, row: CartItem) => (
|
||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.key)} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Search
|
||||
placeholder="Buscar produto por nome ou código..."
|
||||
allowClear
|
||||
style={{ maxWidth: 400 }}
|
||||
onSearch={setQ}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) setQ('');
|
||||
}}
|
||||
/>
|
||||
<Table<ProductSummary>
|
||||
rowKey="id"
|
||||
columns={catalogColumns}
|
||||
dataSource={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 220 }}
|
||||
/>
|
||||
<Divider orientation="left">Itens do Pedido ({cart.length})</Divider>
|
||||
<Table<CartItem>
|
||||
rowKey="key"
|
||||
columns={cartColumns}
|
||||
dataSource={cart}
|
||||
size="small"
|
||||
pagination={false}
|
||||
locale={{ emptyText: 'Nenhum produto adicionado ainda.' }}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 2 — Desconto Global + Observações ───────────────────────────────────
|
||||
|
||||
function ReviewStep({
|
||||
cart,
|
||||
globalDisc,
|
||||
notes,
|
||||
creditLimit,
|
||||
onDiscChange,
|
||||
onNotesChange,
|
||||
}: {
|
||||
cart: CartItem[];
|
||||
globalDisc: number;
|
||||
notes: string;
|
||||
creditLimit: string | null;
|
||||
onDiscChange: (v: number) => void;
|
||||
onNotesChange: (v: string) => void;
|
||||
}) {
|
||||
const itemsSubtotal = cart.reduce(
|
||||
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
||||
0,
|
||||
);
|
||||
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||
const limit = creditLimit ? Number(creditLimit) : null;
|
||||
const creditOk = limit === null || total <= limit;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="Subtotal dos itens" span={2}>
|
||||
<Text strong>{fmt(itemsSubtotal)}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Desconto global do pedido">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={globalDisc}
|
||||
addonAfter="%"
|
||||
style={{ width: 120 }}
|
||||
onChange={(n) => onDiscChange(n ?? 0)}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Total do pedido">
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{fmt(total)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Limite de crédito">{limit ? fmt(limit) : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Situação crédito">
|
||||
<Tag color={creditOk ? 'success' : 'error'}>{creditOk ? 'OK' : 'Acima do limite'}</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Form.Item label="Observações (opcional)">
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Instruções de entrega, referência do comprador, etc."
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3 — Confirmação ─────────────────────────────────────────────────────
|
||||
|
||||
function ConfirmStep({
|
||||
cart,
|
||||
globalDisc,
|
||||
notes,
|
||||
clientName,
|
||||
}: {
|
||||
cart: CartItem[];
|
||||
globalDisc: number;
|
||||
notes: string;
|
||||
clientName: string;
|
||||
}) {
|
||||
const itemsSubtotal = cart.reduce(
|
||||
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
|
||||
0,
|
||||
);
|
||||
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Cliente">{clientName}</Descriptions.Item>
|
||||
<Descriptions.Item label="Produtos">{cart.length} item(ns)</Descriptions.Item>
|
||||
<Descriptions.Item label="Subtotal dos itens">{fmt(itemsSubtotal)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Desconto global">{globalDisc}%</Descriptions.Item>
|
||||
<Descriptions.Item label="Total">
|
||||
<Text strong style={{ fontSize: 18 }}>
|
||||
{fmt(total)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
{notes && <Descriptions.Item label="Observações">{notes}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
<Alert
|
||||
type="info"
|
||||
message="O pedido será criado com status Orçamento ou Aguardando Aprovação, conforme a sua alçada de desconto."
|
||||
showIcon
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
|
||||
|
||||
type SearchParams = { clientId?: string };
|
||||
|
||||
export function NewOrderPage() {
|
||||
const { clientId } = useSearch({ strict: false }) as SearchParams;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: client, isLoading: clientLoading } = useClientDetail(clientId);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [globalDisc, setGlobalDisc] = useState(0);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!clientId) throw new Error('clientId ausente');
|
||||
const body: CreateOrder = {
|
||||
clientId,
|
||||
discountPct: globalDisc,
|
||||
notes: notes || undefined,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
items: cart.map((it) => ({
|
||||
productCode: it.productCode,
|
||||
productName: it.productName,
|
||||
productCategory: it.productCategory,
|
||||
quantity: it.quantity,
|
||||
unitPrice: it.unitPrice,
|
||||
discountPct: it.discountPct,
|
||||
})),
|
||||
};
|
||||
const res = await apiFetch('/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { detail?: string }).detail ?? `Erro ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (order: { id: string }) => {
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const addToCart = (p: ProductSummary) => {
|
||||
setCart((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: p.code,
|
||||
productCode: p.code,
|
||||
productName: p.name,
|
||||
productCategory: p.category,
|
||||
quantity: 1,
|
||||
unitPrice: Number(p.unitPrice),
|
||||
discountPct: 0,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
|
||||
const setQty = (key: string, qty: number) =>
|
||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, quantity: qty } : it)));
|
||||
const setDisc = (key: string, disc: number) =>
|
||||
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, discountPct: disc } : it)));
|
||||
|
||||
if (!clientId)
|
||||
return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />;
|
||||
|
||||
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||
|
||||
if (!client)
|
||||
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||
|
||||
const steps = [{ title: 'Produtos' }, { title: 'Desconto / Obs.' }, { title: 'Confirmar' }];
|
||||
|
||||
const canNext = (step === 0 && cart.length > 0) || step === 1;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 900 }}>
|
||||
<Space align="center" style={{ marginBottom: 16 }}>
|
||||
<Link to="/clientes/$id" params={{ id: clientId }}>
|
||||
← {client.tradeName ?? client.name}
|
||||
</Link>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
Novo Pedido
|
||||
</Title>
|
||||
</Space>
|
||||
|
||||
<Steps current={step} items={steps} style={{ marginBottom: 24 }} />
|
||||
|
||||
{step === 0 && (
|
||||
<ProductStep
|
||||
cart={cart}
|
||||
onAdd={addToCart}
|
||||
onRemove={removeFromCart}
|
||||
onQtyChange={setQty}
|
||||
onDiscChange={setDisc}
|
||||
/>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<ReviewStep
|
||||
cart={cart}
|
||||
globalDisc={globalDisc}
|
||||
notes={notes}
|
||||
creditLimit={client.creditLimit}
|
||||
onDiscChange={setGlobalDisc}
|
||||
onNotesChange={setNotes}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<ConfirmStep
|
||||
cart={cart}
|
||||
globalDisc={globalDisc}
|
||||
notes={notes}
|
||||
clientName={client.tradeName ?? client.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
closable
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Space>
|
||||
{step > 0 && <Button onClick={() => setStep((s) => s - 1)}>Voltar</Button>}
|
||||
{step < 2 && (
|
||||
<Button type="primary" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
|
||||
Próximo
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button type="primary" loading={mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
Confirmar Pedido
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/lib/queries/catalog.ts
Normal file
26
apps/web/src/lib/queries/catalog.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ProductListResponseSchema,
|
||||
type ProductListQuery,
|
||||
type ProductListResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
export function useCatalog(params: Partial<ProductListQuery> = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.q) search.set('q', params.q);
|
||||
if (params.category) search.set('category', params.category);
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.limit) search.set('limit', String(params.limit));
|
||||
|
||||
const qs = search.toString();
|
||||
return useQuery<ProductListResponse>({
|
||||
queryKey: ['catalog', params],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`);
|
||||
if (!res.ok) throw new Error(`catalog error ${res.status}`);
|
||||
return ProductListResponseSchema.parse(await res.json());
|
||||
},
|
||||
staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
|
||||
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
|
||||
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
|
||||
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
|
||||
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage';
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
@@ -43,6 +44,12 @@ const pedidosRoute = createRoute({
|
||||
component: OrdersPage,
|
||||
});
|
||||
|
||||
const novoOrderRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/pedidos/novo',
|
||||
component: NewOrderPage,
|
||||
});
|
||||
|
||||
const pedidoDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/pedidos/$id',
|
||||
@@ -62,6 +69,7 @@ const routeTree = rootRoute.addChildren([
|
||||
clientesRoute,
|
||||
clienteDetailRoute,
|
||||
pedidosRoute,
|
||||
novoOrderRoute,
|
||||
pedidoDetailRoute,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user