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:
@@ -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;
|
||||
|
||||
@@ -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[]>(`
|
||||
// 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,
|
||||
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
|
||||
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 p.ativo = 1
|
||||
WHERE pp.id_pauta = ${idPauta}
|
||||
AND p.ativo = 1
|
||||
AND pp.preco1 > 0
|
||||
${grupoFilter}
|
||||
${searchFilter}
|
||||
ORDER BY p.descricao
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`);
|
||||
|
||||
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||
`),
|
||||
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);
|
||||
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 data: ProdutoSummary[] = rows.map((p) => ({
|
||||
// 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 };
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,10 +83,11 @@ export function CatalogPage() {
|
||||
Catálogo de Produtos
|
||||
</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Search
|
||||
placeholder="Buscar por código ou descrição..."
|
||||
allowClear
|
||||
style={{ width: 320, marginBottom: 16 }}
|
||||
style={{ width: 300 }}
|
||||
onSearch={(v) => {
|
||||
setQ(v);
|
||||
setPage(1);
|
||||
@@ -92,6 +99,21 @@ export function CatalogPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user