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>