From e7cbadcf7e0a615a026368c057e43aee5011c0a0 Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 29 May 2026 14:55:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(catalog):=20filtro=20de=20pre=C3=A7o=20e?= =?UTF-8?q?=20seletor=20de=20pauta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../api/src/app/catalog/catalog.controller.ts | 6 + apps/api/src/app/catalog/catalog.service.ts | 178 +++++++++++------- apps/web/src/cockpits/rafael/CatalogPage.tsx | 60 ++++-- apps/web/src/lib/queries/catalog.ts | 15 ++ .../api-interface/src/lib/product.contract.ts | 10 +- 5 files changed, 185 insertions(+), 84 deletions(-) diff --git a/apps/api/src/app/catalog/catalog.controller.ts b/apps/api/src/app/catalog/catalog.controller.ts index 98c844f..5dc85ff 100644 --- a/apps/api/src/app/catalog/catalog.controller.ts +++ b/apps/api/src/app/catalog/catalog.controller.ts @@ -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 { + return this.catalog.pautas(); + } + @Get() list(@Query() query: ProdutoListQueryDto): Promise { const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery; diff --git a/apps/api/src/app/catalog/catalog.service.ts b/apps/api/src/app/catalog/catalog.service.ts index 291f205..d48c584 100644 --- a/apps/api/src/app/catalog/catalog.service.ts +++ b/apps/api/src/app/catalog/catalog.service.ts @@ -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) {} + async pautas(): Promise { + 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(` + 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 { 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(` - 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(` + 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(` + 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 { @@ -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, diff --git a/apps/web/src/cockpits/rafael/CatalogPage.tsx b/apps/web/src/cockpits/rafael/CatalogPage.tsx index c59c0d3..ee4e4f9 100644 --- a/apps/web/src/cockpits/rafael/CatalogPage.tsx +++ b/apps/web/src/cockpits/rafael/CatalogPage.tsx @@ -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 = [ { title: 'Preço', dataIndex: 'vlPreco1', - width: 110, + width: 120, align: 'right', - render: (v: string) => fmtPrice(v), + render: (v: string) => ( + 0 ? '#389e0d' : '#999' }}> + {fmtPrice(v)} + + ), }, { title: 'Estoque', @@ -66,10 +70,12 @@ const columns: TableColumnsType = [ export function CatalogPage() { const [q, setQ] = useState(''); + const [idPauta, setIdPauta] = useState(); 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 (
@@ -77,21 +83,37 @@ export function CatalogPage() { Catálogo de Produtos - { - setQ(v); - setPage(1); - }} - onChange={(e) => { - if (!e.target.value) { - setQ(''); + + { + setQ(v); setPage(1); - } - }} - /> + }} + onChange={(e) => { + if (!e.target.value) { + setQ(''); + setPage(1); + } + }} + /> +