feat(catalog): filtro de preço e seletor de pauta

- Catálogo só mostra produtos com preço preenchido (vl_preco1 > 0) por default
- Novo endpoint GET /catalog/pautas — retorna as 6 pautas do representante logado
- GET /catalog?idPauta=N — usa preço da pauta selecionada (vw_pauta_produtos)
- CatalogPage: dropdown "Selecionar pauta de preços" com as pautas do rep
- product.contract: adiciona PautaSchema e idPauta no ProdutoListQuerySchema

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 14:55:18 +00:00
parent 1f8a9d872a
commit e7cbadcf7e
5 changed files with 185 additions and 84 deletions

View File

@@ -2,6 +2,7 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '
import { createZodDto } from 'nestjs-zod';
import {
ProdutoListQuerySchema,
type Pauta,
type ProdutoDetail,
type ProdutoListQuery,
type ProdutoListResponse,
@@ -16,6 +17,11 @@ class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@Get('pautas')
pautas(): Promise<Pauta[]> {
return this.catalog.pautas();
}
@Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
Pauta,
ProdutoDetail,
ProdutoListQuery,
ProdutoListResponse,
@@ -44,12 +45,45 @@ interface ProdutoRow {
export class CatalogService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async pautas(): Promise<Pauta[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface PautaRow {
id_pauta: number;
codigo: number;
descricao: string;
}
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
FROM vw_pautas pa
JOIN vw_representantes r ON r.id_empresa = pa.id_empresa
AND pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
ORDER BY pa.codigo
`);
return rows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
}));
}
async list(query: ProdutoListQuery): Promise<ProdutoListResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const { q, codGrupo, page, limit } = query;
const { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit;
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
@@ -57,65 +91,93 @@ export class CatalogService {
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
: '';
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp,
p.codigo,
p.descricao,
p.unidade,
p.vl_preco1::text,
p.cod_grupo,
p.grupo,
p.cod_subgrupo,
p.subgrupo,
p.marca,
p.ativo,
e.qtd_estoque::text,
p.lista_pauta,
p.referencia,
p.descr_det,
p.vl_preco2::text,
p.vl_preco3::text,
p.aliq_ipi::text,
p.peso_liquido::text,
p.qtd_volume::text,
p.lote_mul_venda,
p.preco_promocional::text
FROM vw_produtos p
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.ativo = 1
${grupoFilter}
${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`);
// Com pauta: usa preço específico da pauta. Sem pauta: filtra vl_preco1 > 0.
if (idPauta != null) {
interface PautaItemRow extends ProdutoRow {
preco_pauta: string;
}
const [rows, countRows] = await Promise.all([
prisma.$queryRawUnsafe<PautaItemRow[]>(`
SELECT
p.id_erp, p.codigo, p.descricao, p.unidade,
pp.preco1::text AS preco_pauta,
p.cod_grupo, p.grupo, p.cod_subgrupo, p.subgrupo, p.marca,
p.ativo, e.qtd_estoque::text, p.lista_pauta,
p.referencia, p.descr_det, p.vl_preco2::text, p.vl_preco3::text,
p.aliq_ipi::text, p.peso_liquido::text, p.qtd_volume::text,
p.lote_mul_venda, p.preco_promocional::text
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE pp.id_pauta = ${idPauta}
AND p.ativo = 1
AND pp.preco1 > 0
${grupoFilter}
${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
WHERE pp.id_pauta = ${idPauta} AND p.ativo = 1 AND pp.preco1 > 0
${grupoFilter} ${searchFilter}
`),
]);
const total = parseInt(countRows[0]?.count ?? '0', 10);
return {
data: rows.map((p) => this.mapRow(p, p.preco_pauta)),
total,
page,
limit,
};
}
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_produtos p
WHERE p.ativo = 1
${grupoFilter}
${searchFilter}
`);
const total = parseInt(totalRows[0]?.count ?? '0', 10);
// Sem pauta: produtos com preço base preenchido
const [rows, countRows] = await Promise.all([
prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp, p.codigo, p.descricao, p.unidade,
p.vl_preco1::text,
p.cod_grupo, p.grupo, p.cod_subgrupo, p.subgrupo, p.marca,
p.ativo, e.qtd_estoque::text, p.lista_pauta,
p.referencia, p.descr_det, p.vl_preco2::text, p.vl_preco3::text,
p.aliq_ipi::text, p.peso_liquido::text, p.qtd_volume::text,
p.lote_mul_venda, p.preco_promocional::text
FROM vw_produtos p
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.id_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
${grupoFilter} ${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count FROM vw_produtos p
WHERE p.id_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
${grupoFilter} ${searchFilter}
`),
]);
const total = parseInt(countRows[0]?.count ?? '0', 10);
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit };
}
const data: ProdutoSummary[] = rows.map((p) => ({
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return {
idErp: Number(p.id_erp),
codigo: p.codigo,
descricao: p.descricao,
codigo: (p.codigo ?? '').trim(),
descricao: (p.descricao ?? '').trim(),
unidade: p.unidade,
vlPreco1: p.vl_preco1,
vlPreco1: preco ?? '0',
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo,
grupo: p.grupo ? p.grupo.trim() : null,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo,
marca: p.marca,
subgrupo: p.subgrupo ? p.subgrupo.trim() : null,
marca: p.marca ? p.marca.trim() : null,
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
}));
return { data, total, page, limit };
};
}
async findOne(idErp: number): Promise<ProdutoDetail | null> {
@@ -157,19 +219,7 @@ export class CatalogService {
if (!p) return null;
return {
idErp: Number(p.id_erp),
codigo: p.codigo,
descricao: p.descricao,
unidade: p.unidade,
vlPreco1: p.vl_preco1,
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo,
marca: p.marca,
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
...this.mapRow(p, p.vl_preco1),
referencia: p.referencia,
descricaoDetalhada: p.descr_det,
vlPreco2: p.vl_preco2,

View File

@@ -1,8 +1,8 @@
import { useState } from 'react';
import { Table, Input, Typography, Tag } from 'antd';
import { Table, Input, Select, Space, Typography, Tag } from 'antd';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog } from '../../lib/queries/catalog';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
const { Title } = Typography;
const { Search } = Input;
@@ -45,9 +45,13 @@ const columns: TableColumnsType<ProdutoSummary> = [
{
title: 'Preço',
dataIndex: 'vlPreco1',
width: 110,
width: 120,
align: 'right',
render: (v: string) => fmtPrice(v),
render: (v: string) => (
<span style={{ fontWeight: 600, color: Number(v) > 0 ? '#389e0d' : '#999' }}>
{fmtPrice(v)}
</span>
),
},
{
title: 'Estoque',
@@ -66,10 +70,12 @@ const columns: TableColumnsType<ProdutoSummary> = [
export function CatalogPage() {
const [q, setQ] = useState('');
const [idPauta, setIdPauta] = useState<number | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data, isLoading } = useCatalog({ q: q || undefined, page, limit });
const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
return (
<div style={{ padding: 24 }}>
@@ -77,21 +83,37 @@ export function CatalogPage() {
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('');
<Space style={{ marginBottom: 16 }} wrap>
<Search
placeholder="Buscar por código ou descrição..."
allowClear
style={{ width: 300 }}
onSearch={(v) => {
setQ(v);
setPage(1);
}
}}
/>
}}
onChange={(e) => {
if (!e.target.value) {
setQ('');
setPage(1);
}
}}
/>
<Select
placeholder="Selecionar pauta de preços"
allowClear
loading={pautasLoading}
style={{ width: 340 }}
onChange={(v) => {
setIdPauta(v as number | undefined);
setPage(1);
}}
options={pautas?.map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
}))}
/>
</Space>
<Table<ProdutoSummary>
rowKey="idErp"

View File

@@ -1,17 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import {
PautaSchema,
ProdutoListResponseSchema,
ProdutoDetailSchema,
type ProdutoListQuery,
type ProdutoListResponse,
type ProdutoDetail,
type Pauta,
} from '@sar/api-interface';
import { z } from 'zod';
import { apiFetch } from '../api-client';
export function usePautas() {
return useQuery<Pauta[]>({
queryKey: ['catalog', 'pautas'],
queryFn: async () => {
const res = await apiFetch('/catalog/pautas');
return z.array(PautaSchema).parse(res);
},
staleTime: 10 * 60 * 1000,
});
}
export function useCatalog(params: Partial<ProdutoListQuery> = {}) {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);
if (params.codGrupo) search.set('codGrupo', String(params.codGrupo));
if (params.idPauta) search.set('idPauta', String(params.idPauta));
if (params.page) search.set('page', String(params.page));
if (params.limit) search.set('limit', String(params.limit));

View File

@@ -11,7 +11,7 @@ export const ProdutoSummarySchema = z.object({
codigo: z.string(),
descricao: z.string(),
unidade: z.string().nullable(),
vlPreco1: z.string(),
vlPreco1: z.string(), // preço base (vw_produtos) ou preço da pauta selecionada
codGrupo: z.number().int().nullable(),
grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(),
@@ -21,6 +21,13 @@ export const ProdutoSummarySchema = z.object({
qtdEstoque: z.string().nullable(),
listaParauta: z.number().int().nullable(),
});
export const PautaSchema = z.object({
idPauta: z.number().int(),
codigo: z.number().int(),
descricao: z.string(),
});
export type Pauta = z.infer<typeof PautaSchema>;
export type ProdutoSummary = z.infer<typeof ProdutoSummarySchema>;
// ─── Produto Detail ───────────────────────────────────────────────────────────
@@ -44,6 +51,7 @@ export type ProdutoDetail = z.infer<typeof ProdutoDetailSchema>;
export const ProdutoListQuerySchema = z.object({
q: z.string().optional(),
codGrupo: z.coerce.number().int().optional(),
idPauta: z.coerce.number().int().optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});