refactor(erp): integração direta com banco ERP — schema sar

Revoga ADR 0006 (BD-por-workspace separado). O SAR agora conecta ao
banco PostgreSQL do ERP (módulo SIG) e usa o schema `sar` para tudo.

PRISMA
- Remove: Client, Product, Order, OrderItem, OrderStatusHistory,
  RepTarget, RepDiscountLimit, PushSubscription (modelos isolados)
- Adiciona: Pedido, PedidoItem, HistoricoPedido, AlcadaDesconto,
  MetaRepresentante, PushSubscription (mapeados para sar.*)
- IDs: id_cliente/cod_vendedor/id_empresa são INTEGER (ERP)
- situa: Int (1=Pendente 2=Aprovado 3=Cancelado 4=Faturado)
- JWT: workspace_id:string → id_empresa:number
- URL: inclui ?schema=sar para Prisma rotear ao schema ERP

SERVICES
- ClientsService: $queryRawUnsafe contra sar.vw_clientes + sar.pedidos
- CatalogService: $queryRawUnsafe contra sar.vw_produtos + sar.vw_estoque
- OrdersService: Prisma models Pedido/PedidoItem/HistoricoPedido/AlcadaDesconto
- DashboardService: MetaRepresentante + queries raw para inativos
- NotificationsService: PushSubscription com codVendedor + idEmpresa

CONTRATOS (api-interface)
- client.contract: campos ERP (idCliente, nome, cgcpf, cod_vendedor…)
- order.contract: PedidoSummary/PedidoDetail/CreatePedido + SITUA_LABEL
- product.contract: ProdutoSummary/ProdutoDetail (vw_produtos)
- auth.contract: workspaceId:string → idEmpresa:number

WEB
- Todos os cockpits e queries atualizados para os novos tipos

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 21:51:16 +00:00
parent 246eb28bb1
commit b0b60d7a14
39 changed files with 1433 additions and 1544 deletions

View File

@@ -1,22 +1,13 @@
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } 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',
@@ -27,27 +18,13 @@ const ACTIVITY_LABEL: Record<string, string> = {
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> = [
const orderColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'number',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: OrderSummary) => (
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
@@ -55,9 +32,17 @@ const orderColumns: TableColumnsType<OrderSummary> = [
},
{
title: 'Status',
dataIndex: 'status',
dataIndex: 'situa',
width: 140,
render: (s: OrderStatus) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>,
render: (s: number) => {
const colorMap: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
},
},
{
title: 'Total',
@@ -68,8 +53,8 @@ const orderColumns: TableColumnsType<OrderSummary> = [
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Emitido em',
dataIndex: 'issuedAt',
title: 'Data',
dataIndex: 'dtPedido',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
@@ -77,85 +62,74 @@ const orderColumns: TableColumnsType<OrderSummary> = [
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id);
const navigate = useNavigate();
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
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 }} wrap>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
{client.tradeName ?? client.name}
{client.razao ?? client.nome}
</Title>
<Tag color={FINANCIAL_COLOR[client.financialStatus]}>
{FINANCIAL_LABEL[client.financialStatus]}
</Tag>
<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 }}>
<Descriptions.Item label="Razão Social">{client.name}</Descriptions.Item>
<Descriptions.Item label="CNPJ">{client.taxId}</Descriptions.Item>
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Telefone">{client.phone ?? '—'}</Descriptions.Item>
{addr && (
<Descriptions.Item label="Telefone">
{client.ddd ? `(${client.ddd}) ` : ''}
{client.telefone ?? '—'}
</Descriptions.Item>
{client.endereco && (
<Descriptions.Item label="Endereço" span={2}>
{addr.street}, {addr.number}
{addr.complement ? `, ${addr.complement}` : ''} {addr.district}, {addr.city}/
{addr.state} CEP {addr.zip}
{client.endereco}
{client.numEndereco ? `, ${client.numEndereco}` : ''}
{client.bairro ? `${client.bairro}` : ''}
{client.cep ? ` — CEP ${client.cep}` : ''}
</Descriptions.Item>
)}
<Descriptions.Item label="Limite de Crédito">
{client.creditLimit
? Number(client.creditLimit).toLocaleString('pt-BR', {
{client.limiteCreditoStr
? Number(client.limiteCreditoStr).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 label="Última Compra">
{client.dtUltimaCompra
? new Date(client.dtUltimaCompra).toLocaleDateString('pt-BR')
: '—'}
</Descriptions.Item>
{client.erpCode && (
<Descriptions.Item label="Código ERP">{client.erpCode}</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="left">Últimos 10 Pedidos</Divider>
<Divider orientation="left">Últimos Pedidos</Divider>
<Table<OrderSummary>
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns}
dataSource={orders ?? []}
loading={ordersLoading}
pagination={false}
size="small"
rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
</div>

View File

@@ -1,8 +1,8 @@
import { useState } from 'react';
import { Badge, Input, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
import { Badge, Input, Select, Space, Table, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary, FinancialStatus } from '@sar/api-interface';
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
import { useClientList } from '../../lib/queries/clients';
const { Title } = Typography;
@@ -16,31 +16,27 @@ const ACTIVITY_CONFIG: Record<ActivityStatus, { color: string; label: string }>
inactive: { color: 'error', label: 'Inativo' },
};
const FINANCIAL_CONFIG: Record<FinancialStatus, { color: string; label: string }> = {
regular: { color: 'success', label: 'Regular' },
attention: { color: 'warning', label: 'Atenção' },
blocked: { color: 'error', label: 'Bloqueado' },
};
// ─── Columns ──────────────────────────────────────────────────────────────────
function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsType<ClientSummary> {
return [
{
title: 'Cliente',
dataIndex: 'name',
key: 'name',
render: (name: string, record: ClientSummary) => (
dataIndex: 'nome',
key: 'nome',
render: (nome: string, record: ClientSummary) => (
<Space direction="vertical" size={0}>
<Typography.Link
strong
onClick={() => navigate({ to: '/clientes/$id', params: { id: record.id } })}
onClick={() =>
navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } })
}
>
{name}
{nome}
</Typography.Link>
{record.tradeName && (
{record.razao && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{record.tradeName}
{record.razao}
</Typography.Text>
)}
</Space>
@@ -49,12 +45,12 @@ function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsTyp
},
{
title: 'CNPJ / CPF',
dataIndex: 'taxId',
key: 'taxId',
dataIndex: 'cgcpf',
key: 'cgcpf',
width: 160,
render: (v: string) => (
render: (v: string | null) => (
<Typography.Text className="tabular-nums" style={{ fontSize: 13 }}>
{v}
{v ?? '—'}
</Typography.Text>
),
},
@@ -68,49 +64,20 @@ function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsTyp
return <Badge status={cfg.color as 'success' | 'warning' | 'error'} text={cfg.label} />;
},
},
{
title: 'Situação',
dataIndex: 'financialStatus',
key: 'financialStatus',
width: 110,
render: (v: FinancialStatus) => {
const cfg = FINANCIAL_CONFIG[v];
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: 'Última compra',
dataIndex: 'lastOrderAt',
key: 'lastOrderAt',
dataIndex: 'dtUltimaCompra',
key: 'dtUltimaCompra',
width: 140,
render: (v: string | null, record: ClientSummary) => {
render: (v: string | null) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
const date = new Date(v).toLocaleDateString('pt-BR');
const value = record.lastOrderValue
? `R$ ${Number(record.lastOrderValue).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`
: '';
return (
<Tooltip title={value}>
<Typography.Text className="tabular-nums">{date}</Typography.Text>
</Tooltip>
<Typography.Text className="tabular-nums">
{new Date(v).toLocaleDateString('pt-BR')}
</Typography.Text>
);
},
},
{
title: 'Pedidos abertos',
dataIndex: 'openOrdersCount',
key: 'openOrdersCount',
width: 120,
align: 'center',
render: (v: number) =>
v > 0 ? (
<Tag color="processing" className="tabular-nums">
{v}
</Tag>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
];
}
@@ -141,7 +108,7 @@ export function ClientsPage() {
Carteira de Clientes
</Title>
<Typography.Text type="secondary">
{data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '}
{data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '}
</Typography.Text>
</Space>
@@ -179,7 +146,7 @@ export function ClientsPage() {
<Table<ClientSummary>
columns={columns}
dataSource={data?.data ?? []}
rowKey="id"
rowKey="idCliente"
loading={isLoading || isFetching}
pagination={{
current: page,
@@ -189,11 +156,12 @@ export function ClientsPage() {
showTotal: (total) => `${total} clientes`,
onChange: (p) => setPage(p),
}}
scroll={{ x: 900 }}
scroll={{ x: 700 }}
size="middle"
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () => navigate({ to: '/clientes/$id', params: { id: record.id } }),
onClick: () =>
navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } }),
})}
/>
</Space>

View File

@@ -18,7 +18,7 @@ import {
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 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';
@@ -26,7 +26,7 @@ import { apiFetch } from '../../lib/api-client';
const { Title, Text } = Typography;
const { Search } = Input;
type CartItem = CreateOrderItem & { key: string };
type CartItem = CreatePedidoItem & { key: string };
function calcItemTotal(qty: number, price: number, disc: number): number {
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
@@ -46,7 +46,7 @@ function ProductStep({
onDiscChange,
}: {
cart: CartItem[];
onAdd: (p: ProductSummary) => void;
onAdd: (p: ProdutoSummary) => void;
onRemove: (key: string) => void;
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
@@ -54,20 +54,20 @@ function ProductStep({
const [q, setQ] = useState('');
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
const cartKeys = new Set(cart.map((c) => c.productCode));
const cartKeys = new Set(cart.map((c) => String(c.idProduto)));
const catalogColumns: TableColumnsType<ProductSummary> = [
{ title: 'Código', dataIndex: 'code', width: 100 },
{ title: 'Produto', dataIndex: 'name', ellipsis: true },
const catalogColumns: TableColumnsType<ProdutoSummary> = [
{ title: 'Código', dataIndex: 'codigo', width: 100 },
{ title: 'Produto', dataIndex: 'descricao', ellipsis: true },
{
title: 'Categoria',
dataIndex: 'category',
title: 'Grupo',
dataIndex: 'grupo',
width: 110,
render: (v: string) => <Tag>{v}</Tag>,
render: (v: string | null) => (v ? <Tag>{v}</Tag> : null),
},
{
title: 'Preço',
dataIndex: 'unitPrice',
dataIndex: 'vlPreco1',
width: 110,
align: 'right',
render: (v: string) => fmt(Number(v)),
@@ -75,11 +75,11 @@ function ProductStep({
{
title: '',
width: 80,
render: (_: unknown, row: ProductSummary) => (
render: (_: unknown, row: ProdutoSummary) => (
<Button
size="small"
icon={<PlusOutlined />}
disabled={cartKeys.has(row.code)}
disabled={cartKeys.has(String(row.idErp))}
onClick={() => onAdd(row)}
>
Add
@@ -89,10 +89,10 @@ function ProductStep({
];
const cartColumns: TableColumnsType<CartItem> = [
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
{
title: 'Qtd',
dataIndex: 'quantity',
dataIndex: 'qtd',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
@@ -107,7 +107,7 @@ function ProductStep({
},
{
title: 'Desc %',
dataIndex: 'discountPct',
dataIndex: 'descontoPerc',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
@@ -126,7 +126,7 @@ function ProductStep({
width: 120,
align: 'right',
render: (_: unknown, row: CartItem) =>
fmt(calcItemTotal(row.quantity, row.unitPrice, row.discountPct)),
fmt(calcItemTotal(row.qtd, row.precoUnitario, row.descontoPerc)),
},
{
title: '',
@@ -148,8 +148,8 @@ function ProductStep({
if (!e.target.value) setQ('');
}}
/>
<Table<ProductSummary>
rowKey="id"
<Table<ProdutoSummary>
rowKey="idErp"
columns={catalogColumns}
dataSource={data?.data ?? []}
loading={isLoading}
@@ -175,20 +175,20 @@ function ProductStep({
function ReviewStep({
cart,
globalDisc,
notes,
obs,
creditLimit,
onDiscChange,
onNotesChange,
onObsChange,
}: {
cart: CartItem[];
globalDisc: number;
notes: string;
obs: string;
creditLimit: string | null;
onDiscChange: (v: number) => void;
onNotesChange: (v: string) => void;
onObsChange: (v: string) => void;
}) {
const itemsSubtotal = cart.reduce(
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
0,
);
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
@@ -228,8 +228,8 @@ function ReviewStep({
rows={3}
maxLength={500}
showCount
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
value={obs}
onChange={(e) => onObsChange(e.target.value)}
placeholder="Instruções de entrega, referência do comprador, etc."
/>
</Form.Item>
@@ -242,16 +242,16 @@ function ReviewStep({
function ConfirmStep({
cart,
globalDisc,
notes,
clientName,
obs,
clientNome,
}: {
cart: CartItem[];
globalDisc: number;
notes: string;
clientName: string;
obs: string;
clientNome: string;
}) {
const itemsSubtotal = cart.reduce(
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
(acc, it) => acc + calcItemTotal(it.qtd, it.precoUnitario, it.descontoPerc),
0,
);
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
@@ -259,7 +259,7 @@ function ConfirmStep({
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="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>
@@ -268,11 +268,11 @@ function ConfirmStep({
{fmt(total)}
</Text>
</Descriptions.Item>
{notes && <Descriptions.Item label="Observações">{notes}</Descriptions.Item>}
{obs && <Descriptions.Item label="Observações">{obs}</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."
message="O pedido será criado como Pendente de Aprovação ou Aprovado conforme a sua alçada de desconto."
showIcon
/>
</Space>
@@ -288,61 +288,64 @@ export function NewOrderPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const { data: client, isLoading: clientLoading } = useClientDetail(clientId);
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 [notes, setNotes] = useState('');
const [obs, setObs] = 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,
if (!clientIdNum) throw new Error('clientId ausente');
const body: CreatePedido = {
idCliente: clientIdNum,
descontoPerc: globalDisc,
obs: obs || 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,
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: (order: { id: string }) => {
onSuccess: (pedido: { id: string }) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
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: ProductSummary) => {
const addToCart = (p: ProdutoSummary) => {
setCart((prev) => [
...prev,
{
key: p.code,
productCode: p.code,
productName: p.name,
productCategory: p.category,
quantity: 1,
unitPrice: Number(p.unitPrice),
discountPct: 0,
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, quantity: qty } : it)));
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, discountPct: disc } : it)));
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 }} />;
@@ -360,7 +363,7 @@ export function NewOrderPage() {
<div style={{ padding: 24, maxWidth: 900 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Link to="/clientes/$id" params={{ id: clientId }}>
{client.tradeName ?? client.name}
{client.razao ?? client.nome}
</Link>
<Title level={3} style={{ margin: 0 }}>
Novo Pedido
@@ -382,18 +385,18 @@ export function NewOrderPage() {
<ReviewStep
cart={cart}
globalDisc={globalDisc}
notes={notes}
creditLimit={client.creditLimit}
obs={obs}
creditLimit={client.limiteCreditoStr}
onDiscChange={setGlobalDisc}
onNotesChange={setNotes}
onObsChange={setObs}
/>
)}
{step === 2 && (
<ConfirmStep
cart={cart}
globalDisc={globalDisc}
notes={notes}
clientName={client.tradeName ?? client.name}
obs={obs}
clientNome={client.razao ?? client.nome}
/>
)}

View File

@@ -22,7 +22,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
import type { TableColumnsType } from 'antd';
import { Link, useParams } from '@tanstack/react-router';
import type { OrderItem, OrderStatus, OrderStatusHistory } from '@sar/api-interface';
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderDetail } from '../../lib/queries/orders';
import { useClientOrders } from '../../lib/queries/orders';
import { apiFetch } from '../../lib/api-client';
@@ -33,19 +34,11 @@ const { TextArea } = Input;
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 SITUA_COLOR: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function fmt(v: string | number): string {
@@ -53,17 +46,17 @@ function fmt(v: string | number): string {
}
function buildShareText(order: {
number: string;
clientName: string;
numPedSar: string;
idCliente: number;
total: string;
items: Array<{ productName: string; quantity: string; unitPrice: string }>;
itens: Array<{ descProduto: string | null; qtd: string; precoUnitario: string }>;
}): string {
const lines = [
`*Pedido ${order.number}${order.clientName}*`,
`*Pedido ${order.numPedSar} Cliente ${order.idCliente}*`,
'',
...order.items.map(
...order.itens.map(
(it) =>
`${it.productName} × ${Number(it.quantity).toLocaleString('pt-BR')}${fmt(it.unitPrice)} un.`,
`${it.descProduto ?? '?'} × ${Number(it.qtd).toLocaleString('pt-BR')}${fmt(it.precoUnitario)} un.`,
),
'',
`*Total: ${fmt(order.total)}*`,
@@ -84,56 +77,61 @@ function getRoleFromToken(): string {
// ─── Subcomponents ────────────────────────────────────────────────────────────
const itemColumns: TableColumnsType<OrderItem> = [
{ title: 'Código', dataIndex: 'productCode', width: 100 },
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
{ title: 'Qtd', dataIndex: 'quantity', width: 90, align: 'right' },
const itemColumns: TableColumnsType<PedidoItem> = [
{ title: 'Código', dataIndex: 'codProduto', width: 100 },
{ title: 'Produto', dataIndex: 'descProduto', ellipsis: true },
{ title: 'Qtd', dataIndex: 'qtd', width: 90, align: 'right' },
{
title: 'Preço Unit.',
dataIndex: 'unitPrice',
dataIndex: 'precoUnitario',
width: 120,
align: 'right',
render: (v: string) => fmt(v),
},
{
title: 'Desc %',
dataIndex: 'discountPct',
dataIndex: 'descontoPerc',
width: 80,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Subtotal',
dataIndex: 'subtotal',
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) => fmt(v),
},
];
function HistoryTimeline({ history }: { history: OrderStatusHistory[] }) {
function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
return (
<Timeline
items={history.map((h) => ({
color:
STATUS_COLOR[h.toStatus] === 'success'
SITUA_COLOR[h.situaNova] === 'success'
? 'green'
: STATUS_COLOR[h.toStatus] === 'warning'
: SITUA_COLOR[h.situaNova] === 'warning'
? 'orange'
: STATUS_COLOR[h.toStatus] === 'error'
: SITUA_COLOR[h.situaNova] === 'error'
? 'red'
: 'blue',
children: (
<div>
<Text strong>{STATUS_LABEL[h.toStatus]}</Text>
{h.fromStatus && <Text type="secondary"> (de {STATUS_LABEL[h.fromStatus]})</Text>}
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
{h.situaAnterior != null && (
<Text type="secondary">
{' '}
(de {SITUA_LABEL[h.situaAnterior] ?? String(h.situaAnterior)})
</Text>
)}
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(h.changedAt).toLocaleString('pt-BR')} {h.changedById}
{new Date(h.changedAt).toLocaleString('pt-BR')} cod. {h.changedBy}
</Text>
{h.note && (
{h.nota && (
<div style={{ marginTop: 4 }}>
<Text italic>"{h.note}"</Text>
<Text italic>"{h.nota}"</Text>
</div>
)}
</div>
@@ -154,18 +152,18 @@ function ApproveModal({
}: {
open: boolean;
originalDiscount: string;
onConfirm: (discountPct?: number, note?: string) => void;
onConfirm: (descontoPerc?: number, nota?: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [disc, setDisc] = useState<number | null>(null);
const [note, setNote] = useState('');
const [nota, setNota] = useState('');
return (
<Modal
title="Aprovar Pedido"
open={open}
onOk={() => onConfirm(disc ?? undefined, note || undefined)}
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
onCancel={onCancel}
okText="Confirmar Aprovação"
cancelText="Voltar"
@@ -190,8 +188,8 @@ function ApproveModal({
<Form.Item label="Observação (opcional)">
<TextArea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
value={nota}
onChange={(e) => setNota(e.target.value)}
maxLength={300}
/>
</Form.Item>
@@ -209,20 +207,20 @@ function RejectModal({
loading,
}: {
open: boolean;
onConfirm: (reason: string) => void;
onConfirm: (motivo: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [reason, setReason] = useState('');
const [motivo, setMotivo] = useState('');
return (
<Modal
title="Recusar Pedido"
open={open}
onOk={() => reason.trim() && onConfirm(reason.trim())}
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
onCancel={onCancel}
okText="Confirmar Recusa"
okButtonProps={{ danger: true, disabled: !reason.trim() }}
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
cancelText="Voltar"
confirmLoading={loading}
>
@@ -230,8 +228,8 @@ function RejectModal({
<Form.Item label="Motivo da recusa" required>
<TextArea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
maxLength={500}
showCount
placeholder="Informe o motivo para o representante..."
@@ -248,13 +246,13 @@ export function OrderDetailPage() {
const { id } = useParams({ from: '/pedidos/$id' });
const qc = useQueryClient();
const { data: order, isLoading, error } = useOrderDetail(id);
const { data: clientOrders } = useClientOrders(order?.clientId);
const { data: clientOrders } = useClientOrders(order?.idCliente);
const role = getRoleFromToken();
const canAct = role !== 'rep' && order?.status === 'pending_approval';
const canAct = role !== 'rep' && order?.situa === 1;
const canShare =
role === 'rep' &&
(order?.status === 'approved' || order?.status === 'invoiced') &&
(order?.situa === 2 || order?.situa === 4) &&
typeof navigator !== 'undefined' &&
!!navigator.share;
@@ -263,8 +261,8 @@ export function OrderDetailPage() {
const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
mutationFn: ({ discountPct, note }: { discountPct?: number; note?: string }) =>
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { discountPct, note } }),
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
onSuccess: () => {
setApproveOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
@@ -277,8 +275,8 @@ export function OrderDetailPage() {
});
const rejectMutation = useMutation({
mutationFn: (reason: string) =>
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { reason } }),
mutationFn: (motivo: string) =>
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
onSuccess: () => {
setRejectOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
@@ -295,8 +293,8 @@ export function OrderDetailPage() {
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const timeWaiting =
order.status === 'pending_approval'
? Math.floor((Date.now() - new Date(order.issuedAt).getTime()) / 3_600_000)
order.situa === 1
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
: null;
return (
@@ -304,16 +302,25 @@ export function OrderDetailPage() {
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/pedidos"> Pedidos</Link>
<Title level={3} style={{ margin: 0 }}>
{order.number}
{order.numPedSar}
</Title>
<Badge
status={
STATUS_COLOR[order.status] as 'default' | 'warning' | 'processing' | 'success' | 'error'
(SITUA_COLOR[order.situa] ?? 'default') as
| 'default'
| 'warning'
| 'processing'
| 'success'
| 'error'
}
text={
<Tag color={SITUA_COLOR[order.situa] ?? 'default'}>
{SITUA_LABEL[order.situa] ?? String(order.situa)}
</Tag>
}
text={<Tag color={STATUS_COLOR[order.status]}>{STATUS_LABEL[order.status]}</Tag>}
/>
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red"> Urgente {timeWaiting}h aguardando</Tag>
<Tag color="red">Urgente {timeWaiting}h aguardando</Tag>
)}
{canAct && (
<Space>
@@ -354,38 +361,43 @@ export function OrderDetailPage() {
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="Cliente">
<Link to="/clientes/$id" params={{ id: order.clientId }}>
{order.clientName}
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
Cód. {order.idCliente}
</Link>
</Descriptions.Item>
<Descriptions.Item label="Rep">{order.repId}</Descriptions.Item>
<Descriptions.Item label="Emitido em">
{new Date(order.issuedAt).toLocaleString('pt-BR')}
<Descriptions.Item label="Rep (cód)">{order.codVendedor}</Descriptions.Item>
<Descriptions.Item label="Data">
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
</Descriptions.Item>
{order.approvedAt && (
{order.aprovadoEm && (
<Descriptions.Item label="Aprovado em">
{new Date(order.approvedAt).toLocaleString('pt-BR')} {order.approvedById}
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} cód. {order.aprovadoPor}
</Descriptions.Item>
)}
<Descriptions.Item label="Subtotal">{fmt(order.subtotal)}</Descriptions.Item>
<Descriptions.Item label="Desc. Global">{order.discountPct}%</Descriptions.Item>
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
<Descriptions.Item label="Total">
<Text strong style={{ fontSize: 16 }}>
{fmt(order.total)}
</Text>
</Descriptions.Item>
{order.notes && (
{order.obs && (
<Descriptions.Item label="Observações" span={2}>
{order.notes}
{order.obs}
</Descriptions.Item>
)}
{order.motivoRecusa && (
<Descriptions.Item label="Motivo Recusa" span={2}>
<Text type="danger">{order.motivoRecusa}</Text>
</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="left">Itens ({order.items.length})</Divider>
<Table<OrderItem>
<Divider orientation="left">Itens ({order.itens.length})</Divider>
<Table<PedidoItem>
rowKey="id"
columns={itemColumns}
dataSource={order.items}
dataSource={order.itens}
pagination={false}
size="small"
style={{ marginBottom: 24 }}
@@ -393,7 +405,7 @@ export function OrderDetailPage() {
{clientOrders && clientOrders.length > 0 && (
<>
<Divider orientation="left">Histórico do Cliente</Divider>
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
<Table
rowKey="id"
size="small"
@@ -402,7 +414,7 @@ export function OrderDetailPage() {
columns={[
{
title: 'Nº',
dataIndex: 'number',
dataIndex: 'numPedSar',
width: 110,
render: (n: string, r: { id: string }) => (
<Link to="/pedidos/$id" params={{ id: r.id }}>
@@ -412,9 +424,11 @@ export function OrderDetailPage() {
},
{
title: 'Status',
dataIndex: 'status',
dataIndex: 'situa',
width: 130,
render: (s: OrderStatus) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>,
render: (s: number) => (
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
),
},
{
title: 'Total',
@@ -423,8 +437,8 @@ export function OrderDetailPage() {
render: (v: string) => fmt(v),
},
{
title: 'Emitido em',
dataIndex: 'issuedAt',
title: 'Data',
dataIndex: 'dtPedido',
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
]}
@@ -434,18 +448,18 @@ export function OrderDetailPage() {
)}
<Divider orientation="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.history} />
<HistoryTimeline history={order.historico} />
<ApproveModal
open={approveOpen}
originalDiscount={order.discountPct}
onConfirm={(discountPct, note) => approveMutation.mutate({ discountPct, note })}
originalDiscount={order.descontoPerc}
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
onCancel={() => setApproveOpen(false)}
loading={approveMutation.isPending}
/>
<RejectModal
open={rejectOpen}
onConfirm={(reason) => rejectMutation.mutate(reason)}
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>

View File

@@ -2,52 +2,46 @@ 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 type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } 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 SITUA_COLOR: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
const STATUS_LABEL: Record<OrderStatus, string> = {
budget: 'Orçamento',
pending_approval: 'Ag. Aprovação',
approved: 'Aprovado',
invoiced: 'Faturado',
cancelled: 'Cancelado',
};
const columns: TableColumnsType<OrderSummary> = [
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'number',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: OrderSummary) => (
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Cliente',
dataIndex: 'clientName',
ellipsis: true,
},
{
title: 'Status',
dataIndex: 'status',
dataIndex: 'situa',
width: 150,
render: (s: OrderStatus) => (
render: (s: number) => (
<Badge
status={STATUS_COLOR[s] as 'default' | 'warning' | 'processing' | 'success' | 'error'}
text={<Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>}
status={
(SITUA_COLOR[s] ?? 'default') as
| 'default'
| 'warning'
| 'processing'
| 'success'
| 'error'
}
text={<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>}
/>
),
},
@@ -60,22 +54,22 @@ const columns: TableColumnsType<OrderSummary> = [
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Emitido em',
dataIndex: 'issuedAt',
title: 'Data',
dataIndex: 'dtPedido',
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 [numFilter, setNumFilter] = useState('');
const [situaFilter, setSituaFilter] = useState<number | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data, isLoading } = useOrderList({
number: numberFilter || undefined,
status: statusFilter,
numPedSar: numFilter || undefined,
situa: situaFilter,
page,
limit,
});
@@ -88,16 +82,16 @@ export function OrdersPage() {
<Space style={{ marginBottom: 16 }} wrap>
<Search
placeholder="Buscar por número..."
placeholder="Buscar por número (SAR-NNNNN)..."
allowClear
style={{ width: 220 }}
style={{ width: 240 }}
onSearch={(v) => {
setNumberFilter(v);
setNumFilter(v);
setPage(1);
}}
onChange={(e) => {
if (!e.target.value) {
setNumberFilter('');
setNumFilter('');
setPage(1);
}
}}
@@ -107,25 +101,24 @@ export function OrdersPage() {
allowClear
style={{ width: 160 }}
onChange={(v) => {
setStatusFilter(v as OrderStatus | undefined);
setSituaFilter(v as number | 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' },
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Aprovado' },
{ value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' },
]}
/>
</Space>
<Table<OrderSummary>
<Table<PedidoSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
pagination={{
current: page,
pageSize: limit,

View File

@@ -6,24 +6,17 @@ import {
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { OrderSummary } from '@sar/api-interface';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
const { Title, Text } = Typography;
const STATUS_LABEL: Record<string, string> = {
budget: 'Orçamento',
pending_approval: 'Ag. Aprovação',
approved: 'Aprovado',
invoiced: 'Faturado',
cancelled: 'Cancelado',
};
const STATUS_COLOR: Record<string, string> = {
budget: 'default',
pending_approval: 'warning',
approved: 'processing',
invoiced: 'success',
cancelled: 'error',
const SITUA_COLOR: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function fmt(v: number): string {
@@ -189,7 +182,7 @@ export function RafaelPainel() {
<Flex vertical gap={12}>
{clientesInativos.map((c) => (
<Flex
key={c.id}
key={c.idCliente}
justify="space-between"
align="center"
style={{
@@ -199,8 +192,8 @@ export function RafaelPainel() {
}}
>
<Space direction="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: c.id }}>
<Text strong>{c.name}</Text>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
<Text strong>{c.nome}</Text>
</Link>
{c.ultimaCompraValor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
@@ -241,16 +234,16 @@ export function RafaelPainel() {
<Text type="secondary">Nenhum pedido nos últimos 7 dias.</Text>
) : (
<Flex vertical gap={10}>
{pedidosRecentes.map((o: OrderSummary) => (
{pedidosRecentes.map((o: PedidoSummary) => (
<Flex key={o.id} justify="space-between" align="center">
<Space direction="vertical" size={0}>
<Link to="/pedidos/$id" params={{ id: o.id }}>
<Text strong className="tabular-nums">
{o.number}
{o.numPedSar}
</Text>
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{o.clientName}
Cód. cliente {o.idCliente}
</Text>
</Space>
<Flex gap={8} align="center">
@@ -260,7 +253,9 @@ export function RafaelPainel() {
currency: 'BRL',
})}
</Text>
<Tag color={STATUS_COLOR[o.status]}>{STATUS_LABEL[o.status]}</Tag>
<Tag color={SITUA_COLOR[o.situa] ?? 'default'}>
{SITUA_LABEL[o.situa] ?? String(o.situa)}
</Tag>
</Flex>
</Flex>
))}

View File

@@ -1,28 +1,28 @@
import { Table, Tag, Typography, Badge, Space } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link } from '@tanstack/react-router';
import type { OrderSummary } from '@sar/api-interface';
import type { PedidoSummary } from '@sar/api-interface';
import { useOrderList } from '../../lib/queries/orders';
const { Title } = Typography;
function hoursWaiting(issuedAt: string): number {
return Math.floor((Date.now() - new Date(issuedAt).getTime()) / 3_600_000);
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
const columns: TableColumnsType<OrderSummary> = [
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'number',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: OrderSummary) => (
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{ title: 'Rep', dataIndex: 'repId', width: 130, ellipsis: true },
{ title: 'Cliente', dataIndex: 'clientName', ellipsis: true },
{ title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 },
{ title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 },
{
title: 'Total',
dataIndex: 'total',
@@ -32,15 +32,15 @@ const columns: TableColumnsType<OrderSummary> = [
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Desc. Global',
dataIndex: 'discountPct',
width: 110,
title: 'Desc. %',
dataIndex: 'descontoPerc',
width: 90,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Aguardando',
dataIndex: 'issuedAt',
dataIndex: 'createdAt',
width: 130,
render: (v: string) => {
const h = hoursWaiting(v);
@@ -50,7 +50,7 @@ const columns: TableColumnsType<OrderSummary> = [
{
title: '',
width: 100,
render: (_: unknown, row: OrderSummary) => (
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
@@ -61,9 +61,10 @@ const columns: TableColumnsType<OrderSummary> = [
];
export function ApprovalQueuePage() {
const { data, isLoading } = useOrderList({ status: 'pending_approval', limit: 200 });
// situa=1 = Pendente de Aprovação
const { data, isLoading } = useOrderList({ situa: 1, limit: 200 });
const urgentCount = data?.data.filter((o) => hoursWaiting(o.issuedAt) > 2).length ?? 0;
const urgentCount = data?.data.filter((o) => hoursWaiting(o.createdAt) > 2).length ?? 0;
return (
<div style={{ padding: 24 }}>
@@ -80,12 +81,12 @@ export function ApprovalQueuePage() {
)}
</Space>
<Table<OrderSummary>
<Table<PedidoSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (hoursWaiting(row.issuedAt) > 2 ? 'row-urgent' : '')}
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
pagination={false}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>

View File

@@ -7,7 +7,7 @@ import {
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { OrderSummary } from '@sar/api-interface';
import type { PedidoSummary } from '@sar/api-interface';
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
const { Title, Text } = Typography;
@@ -16,8 +16,8 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function hoursWaiting(issuedAt: string): number {
return Math.floor((Date.now() - new Date(issuedAt).getTime()) / 3_600_000);
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
@@ -30,19 +30,19 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
}
const queueColumns: TableColumnsType<OrderSummary> = [
const queueColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Pedido',
dataIndex: 'number',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: OrderSummary) => (
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{ title: 'Rep', dataIndex: 'repId', width: 120, ellipsis: true },
{ title: 'Cliente', dataIndex: 'clientName', ellipsis: true },
{ title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 },
{ title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 },
{
title: 'Total',
dataIndex: 'total',
@@ -52,7 +52,7 @@ const queueColumns: TableColumnsType<OrderSummary> = [
},
{
title: 'Aguardando',
dataIndex: 'issuedAt',
dataIndex: 'createdAt',
width: 120,
render: (v: string) => {
const h = hoursWaiting(v);
@@ -62,7 +62,7 @@ const queueColumns: TableColumnsType<OrderSummary> = [
{
title: '',
width: 90,
render: (_: unknown, row: OrderSummary) => (
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
@@ -91,7 +91,7 @@ export function SandraPainel() {
}
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.issuedAt) > 2).length;
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
@@ -211,13 +211,13 @@ export function SandraPainel() {
}
extra={<Link to="/aprovacoes">Ver todas</Link>}
>
<Table<OrderSummary>
<Table<PedidoSummary>
rowKey="id"
columns={queueColumns}
dataSource={approvalQueue.slice(0, 8)}
pagination={false}
size="small"
rowClassName={(row) => (hoursWaiting(row.issuedAt) > 2 ? 'row-urgent' : '')}
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
@@ -244,7 +244,7 @@ export function SandraPainel() {
<Flex vertical gap={12}>
{inativosPorRep.map((r) => (
<Flex
key={r.repId}
key={r.codVendedor}
justify="space-between"
align="center"
style={{
@@ -254,7 +254,7 @@ export function SandraPainel() {
}}
>
<Space direction="vertical" size={0}>
<Text strong>{r.repId}</Text>
<Text strong>Rep cód. {r.codVendedor}</Text>
</Space>
<Tag
color={r.inativosCount >= 3 ? 'orange' : 'default'}

View File

@@ -9,11 +9,12 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
type DevUser = { userId: string; role: string; label: string };
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
const DEV_USERS: DevUser[] = [
{ userId: 'user-001', role: 'rep', label: 'Rafael — Rep (user-001)' },
{ userId: 'user-002', role: 'rep', label: 'Rep 2 (user-002)' },
{ userId: 'user-sandra-01', role: 'supervisor', label: 'Sandra — Supervisora' },
{ userId: 'user-manager-01', role: 'manager', label: 'Gerente (user-manager-01)' },
{ userId: '101', role: 'rep', label: 'Rafael — Rep (cod 101)' },
{ userId: '102', role: 'rep', label: 'Rep 2 (cod 102)' },
{ userId: '201', role: 'supervisor', label: 'Sandra — Supervisora (cod 201)' },
{ userId: '301', role: 'manager', label: 'Gerente (cod 301)' },
];
export function DevLogin({ onLogin }: { onLogin: () => void }) {
@@ -26,7 +27,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
try {
const raw = await apiFetch('/auth/dev/token', {
method: 'POST',
body: { userId: user.userId, workspaceId: 'dev-workspace', role: user.role },
body: { userId: user.userId, idEmpresa: 1, role: user.role },
});
const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken);

View File

@@ -13,11 +13,7 @@ export function FoundationStatus() {
if (isPending) {
return (
<Pill
color={brandTokens.textMuted}
label="API…"
tooltip="Verificando conexão com a API"
/>
<Pill color={brandTokens.textMuted} label="API…" tooltip="Verificando conexão com a API" />
);
}
@@ -53,7 +49,7 @@ export function FoundationStatus() {
lines={[
['Service', data.service],
['Version', data.version],
['Workspace', data.workspaceId],
['Empresa', String(data.idEmpresa)],
['Request', data.requestId.slice(0, 8) + '…'],
['Uptime', `${data.uptimeSeconds}s`],
]}

View File

@@ -1,25 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import {
ProductListResponseSchema,
type ProductListQuery,
type ProductListResponse,
ProdutoListResponseSchema,
type ProdutoListQuery,
type ProdutoListResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useCatalog(params: Partial<ProductListQuery> = {}) {
export function useCatalog(params: Partial<ProdutoListQuery> = {}) {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);
if (params.category) search.set('category', params.category);
if (params.codGrupo) search.set('codGrupo', String(params.codGrupo));
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>({
return useQuery<ProdutoListResponse>({
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());
return ProdutoListResponseSchema.parse(await res.json());
},
staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4
});

View File

@@ -11,14 +11,13 @@ import { apiFetch } from '../api-client';
export const CLIENT_KEYS = {
all: ['clients'] as const,
list: (params: Partial<ClientListQuery>) => ['clients', 'list', params] as const,
detail: (id: string) => ['clients', 'detail', id] as const,
detail: (id: number) => ['clients', 'detail', id] as const,
};
export function useClientList(params: Partial<ClientListQuery> = {}) {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.status) qs.set('status', params.status);
if (params.financialStatus) qs.set('financialStatus', params.financialStatus);
if (params.page) qs.set('page', String(params.page));
if (params.limit) qs.set('limit', String(params.limit));
@@ -27,18 +26,20 @@ export function useClientList(params: Partial<ClientListQuery> = {}) {
return useQuery<ClientListResponse, Error>({
queryKey: CLIENT_KEYS.list(params),
queryFn: async () => {
const raw = await apiFetch(`/clients${query ? `?${query}` : ''}`);
return ClientListResponseSchema.parse(raw);
const res = await apiFetch(`/clients${query ? `?${query}` : ''}`);
if (!res.ok) throw new Error(`clients list error ${res.status}`);
return ClientListResponseSchema.parse(await res.json());
},
});
}
export function useClientDetail(id: string) {
export function useClientDetail(id: number | string | undefined) {
return useQuery<ClientDetail, Error>({
queryKey: CLIENT_KEYS.detail(id),
queryKey: CLIENT_KEYS.detail(Number(id)),
queryFn: async () => {
const raw = await apiFetch(`/clients/${id}`);
return ClientDetailSchema.parse(raw);
const res = await apiFetch(`/clients/${id}`);
if (!res.ok) throw new Error(`client detail error ${res.status}`);
return ClientDetailSchema.parse(await res.json());
},
enabled: !!id,
});

View File

@@ -1,56 +1,56 @@
import { useQuery } from '@tanstack/react-query';
import {
OrderListResponseSchema,
OrderDetailSchema,
type OrderListQuery,
type OrderListResponse,
type OrderDetail,
type OrderSummary,
PedidoListResponseSchema,
PedidoDetailSchema,
type PedidoListQuery,
type PedidoListResponse,
type PedidoDetail,
type PedidoSummary,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useOrderList(params: Partial<OrderListQuery> = {}) {
export function useOrderList(params: Partial<PedidoListQuery> = {}) {
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.idCliente) search.set('idCliente', String(params.idCliente));
if (params.situa) search.set('situa', String(params.situa));
if (params.numPedSar) search.set('numPedSar', params.numPedSar);
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>({
return useQuery<PedidoListResponse>({
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());
return PedidoListResponseSchema.parse(await res.json());
},
});
}
export function useOrderDetail(id: string | undefined) {
return useQuery<OrderDetail>({
return useQuery<PedidoDetail>({
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());
return PedidoDetailSchema.parse(await res.json());
},
});
}
export function useClientOrders(clientId: string | undefined) {
return useQuery<OrderSummary[]>({
queryKey: ['clients', clientId, 'orders'],
enabled: !!clientId,
export function useClientOrders(idCliente: number | undefined) {
return useQuery<PedidoSummary[]>({
queryKey: ['clients', idCliente, 'orders'],
enabled: idCliente != null,
queryFn: async () => {
const res = await apiFetch(`/clients/${clientId}/orders`);
const res = await apiFetch(`/orders?idCliente=${idCliente}&limit=10`);
if (!res.ok) throw new Error(`client orders error ${res.status}`);
const data = await res.json();
return data as OrderSummary[];
const data = PedidoListResponseSchema.parse(await res.json());
return data.data;
},
});
}

View File

@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowJs":false,"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":false,"experimentalDecorators":true,"jsx":4,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":10,"useDefineForClassFields":false,"verbatimModuleSyntax":false},"version":"5.9.3"}