feat(web): clientes e catálogo funcionando com dados do ERP

- clients.ts, catalog.ts: corrige bug res.ok/res.json() — apiFetch retorna JSON direto
- catalog.service.ts: corrige nomes de coluna da vw_produtos (descr_det, lista_pauta,
  remove preco_com_ipi inexistente)
- CatalogPage.tsx: nova tela — código, descrição, grupo, marca, preço, estoque
- router.tsx: adiciona rota /catalogo

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 14:46:25 +00:00
parent 24408ecd83
commit 1f8a9d872a
5 changed files with 148 additions and 20 deletions

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { Table, Input, Typography, Tag } from 'antd';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog } from '../../lib/queries/catalog';
const { Title } = Typography;
const { Search } = Input;
function fmtPrice(v: string | null | undefined): string {
const n = Number(v ?? 0);
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
}
const columns: TableColumnsType<ProdutoSummary> = [
{
title: 'Código',
dataIndex: 'codigo',
width: 110,
render: (v: string) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.trim()}</span>,
},
{
title: 'Descrição',
dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => (
<div>
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
</div>
),
},
{
title: 'Und',
dataIndex: 'unidade',
width: 60,
align: 'center',
render: (v: string | null) => v ?? '—',
},
{
title: 'Marca',
dataIndex: 'marca',
width: 130,
render: (v: string | null) => (v ? <Tag>{v.trim()}</Tag> : null),
},
{
title: 'Preço',
dataIndex: 'vlPreco1',
width: 110,
align: 'right',
render: (v: string) => fmtPrice(v),
},
{
title: 'Estoque',
dataIndex: 'qtdEstoque',
width: 90,
align: 'right',
render: (v: string | null) => {
if (v == null) return '—';
const n = Number(v);
return (
<span style={{ color: n > 0 ? 'inherit' : '#f5222d' }}>{n.toLocaleString('pt-BR')}</span>
);
},
},
];
export function CatalogPage() {
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const limit = 50;
const { data, isLoading } = useCatalog({ q: q || undefined, page, limit });
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 16 }}>
Catálogo de Produtos
</Title>
<Search
placeholder="Buscar por código ou descrição..."
allowClear
style={{ width: 320, marginBottom: 16 }}
onSearch={(v) => {
setQ(v);
setPage(1);
}}
onChange={(e) => {
if (!e.target.value) {
setQ('');
setPage(1);
}
}}
/>
<Table<ProdutoSummary>
rowKey="idErp"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
size="small"
pagination={{
current: page,
pageSize: limit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
onChange: (p) => setPage(p),
}}
/>
</div>
);
}

View File

@@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import {
ProdutoListResponseSchema,
ProdutoDetailSchema,
type ProdutoListQuery,
type ProdutoListResponse,
type ProdutoDetail,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
@@ -18,9 +20,19 @@ export function useCatalog(params: Partial<ProdutoListQuery> = {}) {
queryKey: ['catalog', params],
queryFn: async () => {
const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error(`catalog error ${res.status}`);
return ProdutoListResponseSchema.parse(await res.json());
return ProdutoListResponseSchema.parse(res);
},
staleTime: 4 * 60 * 60 * 1000,
});
}
export function useProdutoDetail(id: number | undefined) {
return useQuery<ProdutoDetail>({
queryKey: ['catalog', id],
enabled: id != null,
queryFn: async () => {
const res = await apiFetch(`/catalog/${id}`);
return ProdutoDetailSchema.parse(res);
},
staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4
});
}

View File

@@ -27,8 +27,7 @@ export function useClientList(params: Partial<ClientListQuery> = {}) {
queryKey: CLIENT_KEYS.list(params),
queryFn: async () => {
const res = await apiFetch(`/clients${query ? `?${query}` : ''}`);
if (!res.ok) throw new Error(`clients list error ${res.status}`);
return ClientListResponseSchema.parse(await res.json());
return ClientListResponseSchema.parse(res);
},
});
}
@@ -38,8 +37,7 @@ export function useClientDetail(id: number | string | undefined) {
queryKey: CLIENT_KEYS.detail(Number(id)),
queryFn: async () => {
const res = await apiFetch(`/clients/${id}`);
if (!res.ok) throw new Error(`client detail error ${res.status}`);
return ClientDetailSchema.parse(await res.json());
return ClientDetailSchema.parse(res);
},
enabled: !!id,
});

View File

@@ -6,6 +6,7 @@ import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
import { OrderDetailPage } from '../cockpits/rafael/OrderDetailPage';
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage';
import { CatalogPage } from '../cockpits/rafael/CatalogPage';
import { ApprovalQueuePage } from '../cockpits/sandra/ApprovalQueuePage';
import { SandraPainel } from '../cockpits/sandra/SandraPainel';
import { authStore } from './auth-store';
@@ -76,6 +77,12 @@ const pedidoDetailRoute = createRoute({
component: OrderDetailPage,
});
const catalogoRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/catalogo',
component: CatalogPage,
});
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
@@ -90,6 +97,7 @@ const routeTree = rootRoute.addChildren([
pedidosRoute,
novoOrderRoute,
pedidoDetailRoute,
catalogoRoute,
aprovacoes,
]);