- GET /api/v1/auth/me retorna perfil real do ERP (vw_representantes) - Contrato UserProfile adicionado ao shared api-interface - Hook useCurrentUser() no frontend consome o endpoint - Cockpit rafael → rep, sandra → supervisor (pastas e componentes) - Topbar exibe iniciais do usuário e dropdown com nome, role e "Sair" - Logout limpa token e recarrega para voltar ao DevLogin Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
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 { CreatePedido, CreatePedidoItem, ProdutoSummary } 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 = CreatePedidoItem & { 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: ProdutoSummary) => 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) => String(c.idProduto)));
|
|
|
|
const catalogColumns: TableColumnsType<ProdutoSummary> = [
|
|
{ title: 'Código', dataIndex: 'codigo', width: 100 },
|
|
{ title: 'Produto', dataIndex: 'descricao', ellipsis: true },
|
|
{
|
|
title: 'Grupo',
|
|
dataIndex: 'grupo',
|
|
width: 110,
|
|
render: (v: string | null) => (v ? <Tag>{v}</Tag> : null),
|
|
},
|
|
{
|
|
title: 'Preço',
|
|
dataIndex: 'vlPreco1',
|
|
width: 110,
|
|
align: 'right',
|
|
render: (v: string) => fmt(Number(v)),
|
|
},
|
|
{
|
|
title: '',
|
|
width: 80,
|
|
render: (_: unknown, row: ProdutoSummary) => (
|
|
<Button
|
|
size="small"
|
|
icon={<PlusOutlined />}
|
|
disabled={cartKeys.has(String(row.idErp))}
|
|
onClick={() => onAdd(row)}
|
|
>
|
|
Add
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
const cartColumns: TableColumnsType<CartItem> = [
|
|
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
|
|
{
|
|
title: 'Qtd',
|
|
dataIndex: 'qtd',
|
|
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: 'descontoPerc',
|
|
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.qtd, row.precoUnitario, row.descontoPerc)),
|
|
},
|
|
{
|
|
title: '',
|
|
width: 40,
|
|
render: (_: unknown, row: CartItem) => (
|
|
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.key)} />
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Space orientation="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<ProdutoSummary>
|
|
rowKey="idErp"
|
|
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,
|
|
obs,
|
|
creditLimit,
|
|
onDiscChange,
|
|
onObsChange,
|
|
}: {
|
|
cart: CartItem[];
|
|
globalDisc: number;
|
|
obs: string;
|
|
creditLimit: string | null;
|
|
onDiscChange: (v: number) => void;
|
|
onObsChange: (v: string) => void;
|
|
}) {
|
|
const itemsSubtotal = cart.reduce(
|
|
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
|
|
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 orientation="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={obs}
|
|
onChange={(e) => onObsChange(e.target.value)}
|
|
placeholder="Instruções de entrega, referência do comprador, etc."
|
|
/>
|
|
</Form.Item>
|
|
</Space>
|
|
);
|
|
}
|
|
|
|
// ─── Step 3 — Confirmação ─────────────────────────────────────────────────────
|
|
|
|
function ConfirmStep({
|
|
cart,
|
|
globalDisc,
|
|
obs,
|
|
clientNome,
|
|
}: {
|
|
cart: CartItem[];
|
|
globalDisc: number;
|
|
obs: string;
|
|
clientNome: string;
|
|
}) {
|
|
const itemsSubtotal = cart.reduce(
|
|
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
|
|
0,
|
|
);
|
|
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
|
|
|
|
return (
|
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
|
<Descriptions bordered size="small" column={1}>
|
|
<Descriptions.Item label="Cliente">{clientNome}</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>
|
|
{obs && <Descriptions.Item label="Observações">{obs}</Descriptions.Item>}
|
|
</Descriptions>
|
|
<Alert
|
|
type="info"
|
|
message="O pedido será criado como Pendente de Aprovação ou Aprovado 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 clientIdNum = clientId ? Number(clientId) : undefined;
|
|
const { data: client, isLoading: clientLoading } = useClientDetail(clientIdNum);
|
|
|
|
const [step, setStep] = useState(0);
|
|
const [cart, setCart] = useState<CartItem[]>([]);
|
|
const [globalDisc, setGlobalDisc] = useState(0);
|
|
const [obs, setObs] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (!clientIdNum) throw new Error('clientId ausente');
|
|
const body: CreatePedido = {
|
|
idCliente: clientIdNum,
|
|
descontoPerc: globalDisc,
|
|
obs: obs || undefined,
|
|
idempotencyKey: crypto.randomUUID(),
|
|
itens: cart.map((it, idx) => ({
|
|
idProduto: it.idProduto,
|
|
codProduto: it.codProduto,
|
|
descProduto: it.descProduto,
|
|
ordem: idx + 1,
|
|
qtd: it.qtd,
|
|
precoUnitario: it.precoUnitario,
|
|
descontoPerc: it.descontoPerc,
|
|
})),
|
|
};
|
|
return apiFetch('/orders', { method: 'POST', body });
|
|
},
|
|
onSuccess: (pedido: { id: string }) => {
|
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
|
void qc.invalidateQueries({ queryKey: ['clients', clientIdNum] });
|
|
void navigate({ to: '/pedidos/$id', params: { id: pedido.id } });
|
|
},
|
|
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
|
|
});
|
|
|
|
const addToCart = (p: ProdutoSummary) => {
|
|
setCart((prev) => [
|
|
...prev,
|
|
{
|
|
key: String(p.idErp),
|
|
idProduto: p.idErp,
|
|
codProduto: p.codigo,
|
|
descProduto: p.descricao,
|
|
ordem: prev.length + 1,
|
|
qtd: 1,
|
|
precoUnitario: Number(p.vlPreco1),
|
|
descontoPerc: 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, qtd: qty } : it)));
|
|
const setDisc = (key: string, disc: number) =>
|
|
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, descontoPerc: 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.razao ?? client.nome}
|
|
</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}
|
|
obs={obs}
|
|
creditLimit={client.limiteCreditoStr}
|
|
onDiscChange={setGlobalDisc}
|
|
onObsChange={setObs}
|
|
/>
|
|
)}
|
|
{step === 2 && (
|
|
<ConfirmStep
|
|
cart={cart}
|
|
globalDisc={globalDisc}
|
|
obs={obs}
|
|
clientNome={client.razao ?? client.nome}
|
|
/>
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|