Compare commits

..

16 Commits

Author SHA1 Message Date
dfdcca4674 feat(web): switch ativar/desativar em promocoes e regras de desconto
Coluna "Ativa" com switch nas tabelas das duas abas (liga/desliga na
hora, sem abrir o modal) e campo Ativa no modal de edicao. Desativada,
a promocao/regra sai imediatamente do catalogo e da alcada.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:57:16 +00:00
76cd90ae95 feat(api,web): politicas comerciais com multi-selecao de produtos, grupos e subgrupos
- Promocao: produtos com busca remota por codigo/descricao (multi) e
  grupos com busca por nome/codigo (multi) - backend cria uma promocao
  por alvo na mesma transacao ({ids})
- Regra de desconto: grupos e subgrupos multi (subgrupos filtram pelos
  grupos escolhidos) - uma regra por alvo
- Edicao continua por linha, com selects de busca no lugar dos inputs
  de texto livre
- Modais maiores (760px de largura, corpo com altura minima e rolagem)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:52:58 +00:00
756e56d8d3 feat(api,web): catalogo na area do gerente com todas as pautas e precos
- Menu Catalogo no cockpit do gerente (rota /catalogo existente)
- /catalog/pautas: gestor recebe TODAS as pautas ativas com contagem de
  reps que usam cada uma (rep segue vendo so as suas cod_pauta1..6)
- Detalhe do produto: gestor ve o preco em todas as pautas (rep segue
  restrito as dele)
- Select de pauta mostra "N reps" quando disponivel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:40:34 +00:00
725a94ede1 feat(api,web): meta de positivacao diaria salva no servidor com botao Salvar
Antes a meta vivia no localStorage do navegador (perdia em outro
dispositivo). Agora: coluna meta_positivacao_dia em config_empresa,
PUT /dashboard/manager/meta-positivacao-dia (gerente/admin), meta vem
no dashboard e o card tem botao Salvar (desabilitado sem mudanca).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:33:38 +00:00
b6b16e1e21 feat(api,web): positivacao de novos clientes por representante no painel gerencial
- novosClientes por rep no dashboard do gerente: clientes da carteira
  cuja PRIMEIRA compra (historico todo do ERP) caiu no periodo
- Coluna "Novos no mes" (tag verde +N, ordenavel) na tabela de
  positivacao + total de novos clientes no cabecalho do card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:23:13 +00:00
54ef63f5c7 feat(web): sugestao de comprados anteriormente fechada por padrao com banner de destaque
Painel do historico do cliente no lancamento agora abre sempre fechado,
com banner ambar chamativo ("Este cliente ja comprou N produtos com
voce") e botao Ver produtos - o rep le e escolhe abrir, em vez de a
lista ocupar a tela direto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:15:18 +00:00
2ff1f9328a feat(api,web): peso total (kg) e cubagem (m3) do pedido na impressao
- vw_produtos passa a expor vol_m3 (coluna nova no FIM da view - CREATE
  OR REPLACE exige; aplicada no dev)
- PedidoDetail calcula pesoTotal/m3Total (qtd x peso_liquido/vol_m3),
  tanto para pedidos SAR quanto ERP
- Impressao mostra "Peso total: X kg" e "Volume: Y m3" ao lado dos
  totais (some quando zerado)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:59:27 +00:00
8a5db57f35 feat(api,web): logo da empresa na impressao e cabecalho A4 compacto
- Tabela sar.config_empresa (logo em data URL por empresa matriz) com
  migration idempotente, espelho no schema SQL e GRANT no provision
- PUT /catalog/company/logo (gerente/admin) e logoBase64 no GET
  /catalog/company - vale para a impressao de todos os representantes
- Nova tela /ger/config "Configuracoes" com upload/preview/remocao do
  logo (PNG/JPG/WebP ate 500KB)
- Cabecalho da impressao redesenhado: logo a esquerda, dados da empresa
  condensados em 3 linhas, numero/status/data compactos a direita -
  ocupa ~metade da altura anterior no A4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:48:05 +00:00
b33293e79b fix(web): clique nos 3 pontos da lista de pedidos nao abre mais o detalhe junto
O clique no botao de acoes propagava para o onClick da linha, abrindo o
detalhe por cima do menu. stopPropagation no bloco de acoes: o menu abre
sozinho e so a opcao escolhida executa algo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:27:24 +00:00
1e11de7f31 feat(api,web): edicao de orcamento e cancelamento com motivo
- PATCH /orders/:id edita orcamento (situa 0): substitui itens,
  recalcula totais, valida alcada do dono do pedido; depois de
  transmitido nao e mais editavel (400)
- Menu "Editar orcamento" abre o lancamento pre-carregado
  (/pedidos/novo?editar=id) com cliente, itens, pauta, forma de
  pagamento, obs e endereco; salvar faz PATCH em vez de POST
- Detalhe do pedido passa a incluir idPauta/codFormapag e
  codGrupo/codSubgrupo por item (teto de desconto na edicao)
- Cancelar pedido abre card com motivo (select) + descricao livre;
  gravado em motivo_recusa e no historico (ASCII: banco ERP e LATIN1,
  em-dash quebrava o UPDATE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 19:16:55 +00:00
35fd773f14 feat(api,web): alcada bloqueia ja a criacao do orcamento, nao so a transmissao
- create() valida desconto global e por item (mesma regra do transmit):
  Concluir com desconto acima do teto retorna 400 com a mensagem do
  item que passou; transmit segue validando (defesa em profundidade)
- Botao Concluir desabilita em tempo real com o motivo quando algum
  item esta acima do teto; caminho offline tambem barrado no submit
- Mensagem de alcada generalizada ("para continuar")

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:56:13 +00:00
673130d60b feat(web,api): teto de desconto em tempo real no carrinho do pedido
- GET /politicas/alcada: limites de desconto do proprio usuario logado
- Carrinho (desktop e mobile) mostra "teto: X%" sob o campo de desconto
  de cada item - max(alcada do grupo, promocao, regra vigente), espelho
  do assertAlcadaItens; acima do teto o campo fica vermelho com aviso de
  que a transmissao sera bloqueada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:46:58 +00:00
43daf7ad6d fix(api): promocao/regra de desconto e teto da alcada, nao folga somada
Com alcada base 5% e regra de 15%, o limite do item era 5+15=20% e um
desconto de 18% passava na transmissao. Agora vale o MAIOR entre alcada
do grupo, promocao e regra vigente: regra de 15% bloqueia acima de 15%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:40:06 +00:00
d5279576d7 fix(web): crypto.randomUUID indisponivel fora de contexto seguro quebrava a transmissao
Acesso pelo IP da rede (http://192.168...) nao e contexto seguro e o
navegador nao expoe crypto.randomUUID, derrubando a conclusao do pedido.
Helper randomUUID() cai para UUID v4 via crypto.getRandomValues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:19:41 +00:00
36430fde63 feat(web): desconto de regra nao vem mais pre-preenchido no carrinho
Item entra com 0% mesmo com promocao/regra vigente: o selo de condicao
comercial avisa o rep, que concede o desconto apenas se o cliente
negociar. A alcada no backend segue aceitando ate o % da regra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:00:06 +00:00
afa6171919 Merge branch 'fix/rep-authz-e-front': regras de desconto, usuarios dev por papel e escopo do supervisor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:58:19 +00:00
31 changed files with 1562 additions and 283 deletions

View File

@@ -0,0 +1,10 @@
-- CreateTable: sar.config_empresa (configuracoes por empresa - logo da impressao)
-- logo_base64: data URL (data:image/png;base64,...) usado no cabecalho do pedido.
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
CREATE TABLE IF NOT EXISTS sar.config_empresa (
id_empresa INTEGER PRIMARY KEY,
logo_base64 TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,5 @@
-- Meta diaria de positivacao do painel gerencial - salva por empresa para
-- nao depender do navegador do gerente.
-- Idempotente (provision roda o schema espelho antes do migrate deploy).
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
ALTER TABLE sar.config_empresa ADD COLUMN IF NOT EXISTS meta_positivacao_dia INTEGER;

View File

@@ -195,6 +195,20 @@ model RegraDesconto {
@@map("regras_desconto")
}
// ─── ConfigEmpresa ───────────────────────────────────────────────────────────
//
// Configurações por empresa. logo_base64: data URL exibido no cabeçalho da
// impressão do pedido de todos os representantes.
model ConfigEmpresa {
idEmpresa Int @id @map("id_empresa")
logoBase64 String? @map("logo_base64")
metaPositivacaoDia Int? @map("meta_positivacao_dia")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("config_empresa")
}
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
//
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)

View File

@@ -1,8 +1,20 @@
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
NotFoundException,
Param,
ParseIntPipe,
Put,
Query,
} from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ProdutoListQuerySchema,
UpdateLogoBodySchema,
type EmpresaInfo,
type UpdateLogoBody,
type FormaPagamento,
type Municipio,
type Pauta,
@@ -13,6 +25,7 @@ import {
import { CatalogService } from './catalog.service';
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
class UpdateLogoDto extends createZodDto(UpdateLogoBodySchema) {}
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
@@ -40,6 +53,12 @@ export class CatalogController {
return this.catalog.company();
}
@Put('company/logo')
@HttpCode(204)
updateLogo(@Body() body: UpdateLogoDto): Promise<void> {
return this.catalog.updateLogo(body as unknown as UpdateLogoBody);
}
@Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
UpdateLogoBody,
FormaPagamento,
Municipio,
Pauta,
@@ -101,6 +102,8 @@ export class CatalogService {
const r = rows[0];
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
const config = await prisma.configEmpresa.findUnique({ where: { idEmpresa } });
const clean = (v: string | null) => {
const t = (v ?? '').trim();
return t === '' ? null : t;
@@ -120,9 +123,28 @@ export class CatalogService {
cep: clean(r.cep),
telefone: clean(r.telefone),
email: clean(r.email),
logoBase64: config?.logoBase64 ?? null,
};
}
// Logo do cabeçalho da impressão — só gerente/admin altera; vale para todos
// os representantes da empresa (gravado na matriz, mesma fonte do company()).
async updateLogo(body: UpdateLogoBody): Promise<void> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem alterar o logo da empresa');
}
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
await prisma.configEmpresa.upsert({
where: { idEmpresa },
create: { idEmpresa, logoBase64: body.logoBase64 },
update: { logoBase64: body.logoBase64 },
});
}
async municipios(q?: string): Promise<Municipio[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -158,13 +180,19 @@ export class CatalogService {
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const role = this.cls.get('role') ?? 'rep';
interface PautaRow {
id_pauta: number;
codigo: number;
descricao: string;
qtd_reps?: string;
}
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
// Rep vê só as pautas atribuídas a ele (cod_pauta1..6); gestor vê TODAS as
// pautas ativas, com a contagem de reps que usam cada uma.
const rows =
role === 'rep'
? 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 pa.codigo IN (
@@ -175,12 +203,26 @@ export class CatalogService {
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
ORDER BY pa.codigo
`)
: await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao,
COUNT(DISTINCT r.codigo)::text AS qtd_reps
FROM vw_pautas pa
LEFT JOIN vw_representantes r ON 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
GROUP BY pa.id_pauta, pa.codigo, pa.descricao
ORDER BY pa.codigo
`);
return rows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
...(r.qtd_reps != null && { qtdReps: Number(r.qtd_reps) }),
}));
}
@@ -337,12 +379,16 @@ export class CatalogService {
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
AND pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
JOIN vw_representantes r ON pa.codigo IN (
${
// Rep vê só as pautas dele; gestor vê os preços de TODAS as pautas
(this.cls.get('role') ?? 'rep') === 'rep'
? `JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
) AND r.codigo = ${codVendedor}`
: ''
}
WHERE pp.id_produto = ${idErp}
AND r.codigo = ${codVendedor}
AND pp.preco1 > 0
ORDER BY pa.codigo
`),

View File

@@ -1,9 +1,18 @@
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
import { Body, Controller, ForbiddenException, Get, HttpCode, Put, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
import { createZodDto } from 'nestjs-zod';
import {
SaveMetaPositivacaoBodySchema,
type RepDashboard,
type SupervisorDashboard,
type ManagerDashboard,
type SaveMetaPositivacaoBody,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service';
class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {}
@Controller({ path: 'dashboard' })
export class DashboardController {
constructor(
@@ -31,6 +40,14 @@ export class DashboardController {
return this.dashboard.supervisorDashboard();
}
@Put('manager/meta-positivacao-dia')
@HttpCode(204)
saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise<void> {
this.assertGestor();
const parsed = SaveMetaPositivacaoBodySchema.parse(body) as SaveMetaPositivacaoBody;
return this.dashboard.saveMetaPositivacaoDia(parsed.meta);
}
@Get('manager')
managerDashboard(
@Query('mes') mes?: string,

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
RepDashboard,
@@ -418,6 +418,10 @@ export class DashboardService {
dia: string;
clientes: string;
}
interface NovosClientesRow {
cod_vendedor: number;
novos: string;
}
const [
statsRows,
@@ -428,6 +432,7 @@ export class DashboardService {
metaGrupoRows,
realizadoGrupoRows,
positivacaoDiariaRows,
novosClientesRows,
] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
@@ -535,6 +540,21 @@ export class DashboardService {
GROUP BY dt_pedido::date
ORDER BY dia
`),
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
SELECT c.cod_vendedor, COUNT(*)::text AS novos
FROM (
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
GROUP BY id_cliente
) f
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
ON c.id_cliente = f.id_cliente
WHERE f.primeira_compra >= '${monthStartStr}'
AND f.primeira_compra <= '${monthEndStr}'
GROUP BY c.cod_vendedor
`),
]);
const pedidosMes = Number(statsRows[0]?.count ?? 0);
@@ -585,6 +605,14 @@ export class DashboardService {
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
// Meta diária de positivação salva pelo gerente (config da empresa matriz)
const config = await prisma.configEmpresa.findUnique({
where: { idEmpresa: idEmpresaMatriz },
});
const novosMap = new Map(
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
);
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
const total = Number(r.total_clientes);
const positivados = Number(r.clientes_positivados);
@@ -594,6 +622,7 @@ export class DashboardService {
totalClientes: total,
clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
};
});
@@ -611,10 +640,29 @@ export class DashboardService {
dia: r.dia,
clientes: Number(r.clientes),
})),
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
syncedAt: now.toISOString(),
};
}
// Salva a meta diária de positivação (config_empresa) — só gerente/admin.
async saveMetaPositivacaoDia(meta: number): Promise<void> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem salvar a meta de positivação');
}
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const value = meta > 0 ? meta : null;
await prisma.configEmpresa.upsert({
where: { idEmpresa },
create: { idEmpresa, metaPositivacaoDia: value },
update: { metaPositivacaoDia: value },
});
}
async supervisorDashboard(): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');

View File

@@ -15,12 +15,16 @@ import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
AprovarPedidoSchema,
CancelPedidoSchema,
CreatePedidoSchema,
UpdatePedidoSchema,
PedidoErpConsultaQuerySchema,
PedidoListQuerySchema,
RecusarPedidoSchema,
type AprovarPedido,
type CancelPedido,
type CreatePedido,
type UpdatePedido,
type PedidoDetail,
type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse,
@@ -33,6 +37,8 @@ import { OrdersService } from './orders.service';
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@@ -85,8 +91,22 @@ export class OrdersController {
}
@Patch(':id/cancel')
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.cancel(id);
cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: CancelPedidoDto,
): Promise<PedidoDetail> {
const parsed = CancelPedidoSchema.parse(body) as CancelPedido;
return this.orders.cancel(id, parsed);
}
// Edição de orçamento (situa 0) — depois de transmitido não é mais editável
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: UpdatePedidoDto,
): Promise<PedidoDetail> {
const parsed = UpdatePedidoSchema.parse(body) as UpdatePedido;
return this.orders.update(id, parsed);
}
@Get('erp/:idPedido')

View File

@@ -3,7 +3,9 @@ import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
AprovarPedido,
CancelPedido,
CreatePedido,
UpdatePedido,
PedidoDetail,
PedidoErpConsultaItem,
PedidoErpConsultaQuery,
@@ -330,8 +332,56 @@ export class OrdersService {
return this.mapDetail(o);
}
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
// notificação ao supervisor acontecem no transmit(), não aqui.
// Valida a alçada de um payload de criação/edição (mesma regra do transmit):
// desconto global e por item contra alcada + promoção/regra do dono do pedido.
private async assertAlcadaDto(
codVendedor: number,
dto: {
descontoPerc: number;
idPauta?: number;
itens: {
ordem: number;
idProduto: number;
codProduto?: string;
precoUnitario: number;
descontoPerc: number;
}[];
},
): Promise<void> {
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 limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
const limiteDefault = limitMap.get(0) ?? 5;
if (dto.descontoPerc > limiteDefault) {
throw new BadRequestException(
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para continuar.`,
);
}
await this.assertAlcadaItens(
{
codVendedor,
descontoPerc: dto.descontoPerc,
idPauta: dto.idPauta ?? null,
itens: dto.itens.map((it) => ({
ordem: it.ordem,
idProduto: it.idProduto,
codProduto: it.codProduto ?? null,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
})),
},
limitMap,
limiteDefault,
);
}
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
// JÁ AQUI (bloqueia o Concluir com a mensagem do que passou) e de novo no
// transmit() — defesa em profundidade, pois aprovação/ERP podem mudar valores.
// A notificação ao supervisor continua só no transmit().
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreatePedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
@@ -365,6 +415,9 @@ export class OrdersService {
if (existing) return this.mapDetail(existing);
}
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
await this.assertAlcadaDto(codVendedor, dto);
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
@@ -496,23 +549,23 @@ export class OrdersService {
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
// global — deve caber no limite do grupo do produto (alcada_desconto por
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o
// desconto lançado no item E desconto disfarçado de preço unitário reduzido.
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem
// referência valida só pelo campo de desconto.
// global — deve caber no maior valor entre a alçada do grupo do produto
// (alcada_desconto por codGrupo; 0 = default), a promoção ativa
// (produto/grupo) e a regra de desconto vigente (grupo/subgrupo/rep) — teto,
// não soma. Cobre o desconto lançado no item E desconto disfarçado de preço
// unitário reduzido. Preço de tabela: pauta do pedido > promocional > preço
// base; produto sem referência valida só pelo campo de desconto.
private async assertAlcadaItens(
pedido: {
codVendedor: number;
descontoPerc: Prisma.Decimal;
descontoPerc: Prisma.Decimal | number;
idPauta: number | null;
itens: {
ordem: number;
idProduto: number;
codProduto: string | null;
precoUnitario: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
precoUnitario: Prisma.Decimal | number;
descontoPerc: Prisma.Decimal | number;
}[];
},
limitMap: Map<number, number>,
@@ -601,9 +654,12 @@ export class OrdersService {
)
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
const limiteItem =
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
Math.max(promoPct, regraPct);
// Promoção/regra definem TETO próprio, não somam à alçada: com alçada 5%
// e regra de 15%, o máximo do item é 15% (não 20%). Vale o maior entre a
// alçada do grupo e a melhor condição vigente que alcança o produto.
const alcadaBase =
codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault;
const limiteItem = Math.max(alcadaBase, promoPct, regraPct);
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
@@ -629,7 +685,7 @@ export class OrdersService {
if (problemas.length > 0) {
throw new BadRequestException(
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`,
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para continuar.`,
);
}
}
@@ -743,7 +799,98 @@ export class OrdersService {
return this.mapDetail(final);
}
async cancel(id: string): Promise<PedidoDetail> {
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
const repFilter =
role === 'rep'
? { codVendedor }
: role === 'supervisor'
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
: {};
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException(
'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
);
// PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
dto.idCliente,
);
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
}
}
// Alçada do DONO do pedido (supervisor editando usa o limite do rep)
await this.assertAlcadaDto(pedido.codVendedor, dto);
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
return {
ordem: it.ordem,
idProduto: it.idProduto,
codProduto: it.codProduto ?? null,
descProduto: it.descProduto,
qtd: it.qtd,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
descontoValor,
total,
};
});
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
const final = await prisma.pedido.update({
where: { id },
data: {
idCliente: dto.idCliente,
idPauta: dto.idPauta ?? null,
codFormapag: dto.codFormapag ?? null,
totalProdutos,
total,
descontoPerc: dto.descontoPerc,
descontoValor: descontoValorGlobal,
obs: dto.obs ?? null,
endEntrega: dto.endEntrega ?? null,
itens: { deleteMany: {}, create: itemsData },
historico: {
create: [
{
situaAnterior: SITUA_ORCAMENTO,
situaNova: SITUA_ORCAMENTO,
changedBy: codVendedor,
changedAt: new Date(),
nota: 'Orçamento editado',
},
],
},
},
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(final);
}
async cancel(id: string, dto: CancelPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
@@ -757,8 +904,15 @@ export class OrdersService {
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
const now = new Date();
// ATENCAO: banco ERP e LATIN1 - sem em-dash/caracteres fora do LATIN1 aqui
const motivoCompleto = dto.descricao?.trim()
? `${dto.motivo} - ${dto.descricao.trim()}`
: dto.motivo;
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
await prisma.pedido.update({
where: { id },
data: { situa: SITUA_CANCELADO, motivoRecusa: motivoCompleto },
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
@@ -766,7 +920,7 @@ export class OrdersService {
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: 'Cancelado pelo representante',
nota: `Cancelado pelo representante - ${motivoCompleto}`,
},
});
@@ -818,6 +972,8 @@ export class OrdersService {
codVendedor: number;
situa: number;
dtPedido: Date;
idPauta: number | null;
codFormapag: number | null;
total: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
descontoValor: Prisma.Decimal;
@@ -856,6 +1012,41 @@ export class OrdersService {
}[];
}): Promise<PedidoDetail> {
const names = await this.lookupNames(o.idCliente, o.codVendedor);
// Grupo/subgrupo dos produtos (vw_produtos, empresa matriz) — a edição do
// orçamento usa para calcular o teto de desconto por item no carrinho.
const prisma = this.cls.get('prisma');
const idEmpresa = this.cls.get('idEmpresa');
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const prodIds = [...new Set(o.itens.map((i) => i.idProduto))];
const grupoRows =
prisma && prodIds.length > 0
? await prisma.$queryRawUnsafe<
{
id_erp: number;
cod_grupo: number | null;
cod_subgrupo: number | null;
peso_liquido: string | null;
vol_m3: string | null;
}[]
>(
`SELECT id_erp, cod_grupo, cod_subgrupo, peso_liquido::text, vol_m3::text
FROM vw_produtos
WHERE id_empresa = ${idMatriz} AND id_erp IN (${prodIds.join(',')})`,
)
: [];
const grupoMap = new Map(grupoRows.map((g) => [Number(g.id_erp), g]));
// Peso total (kg) e cubagem (m3) do pedido: soma de qtd x peso/vol do produto
let pesoTotal = 0;
let m3Total = 0;
for (const it of o.itens) {
const prod = grupoMap.get(it.idProduto);
const qtd = Number(it.qtd);
pesoTotal += qtd * Number(prod?.peso_liquido ?? 0);
m3Total += qtd * Number(prod?.vol_m3 ?? 0);
}
return {
id: o.id,
numPedSar: o.numPedSar,
@@ -866,6 +1057,10 @@ export class OrdersService {
nomeVendedor: names.nomeVendedor,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
idPauta: o.idPauta,
codFormapag: o.codFormapag,
pesoTotal: pesoTotal.toFixed(3),
m3Total: m3Total.toFixed(4),
total: decimalToString(o.total),
descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs,
@@ -894,6 +1089,14 @@ export class OrdersService {
precoUnitario: decimalToString(it.precoUnitario),
descontoPerc: decimalToString(it.descontoPerc),
total: decimalToString(it.total),
codGrupo:
grupoMap.get(it.idProduto)?.cod_grupo != null
? Number(grupoMap.get(it.idProduto)?.cod_grupo)
: null,
codSubgrupo:
grupoMap.get(it.idProduto)?.cod_subgrupo != null
? Number(grupoMap.get(it.idProduto)?.cod_subgrupo)
: null,
})),
historico: o.historico.map((h) => ({
id: h.id,
@@ -948,6 +1151,8 @@ export class OrdersService {
preco_unitario: string;
desconto_perc: string;
total: string;
peso_liquido: string | null;
vol_m3: string | null;
}
const vendedorFilter =
@@ -980,7 +1185,8 @@ export class OrdersService {
SELECT ei.ordem, ei.id_produto,
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
ei.qtd::text, ei.preco_unitario::text,
ei.desconto_perc::text, ei.total::text
ei.desconto_perc::text, ei.total::text,
p.peso_liquido::text AS peso_liquido, p.vol_m3::text AS vol_m3
FROM vw_peditens_erp ei
LEFT JOIN vw_produtos p
ON p.id_erp = ei.id_produto
@@ -993,6 +1199,15 @@ export class OrdersService {
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
const h = headerRows[0];
const pesoTotalErp = itemRows.reduce(
(acc, it) => acc + Number(it.qtd) * Number(it.peso_liquido ?? 0),
0,
);
const m3TotalErp = itemRows.reduce(
(acc, it) => acc + Number(it.qtd) * Number(it.vol_m3 ?? 0),
0,
);
return {
id: `erp-${h.id_pedido}`,
numPedSar: (h.num_ped_sar ?? '').trim(),
@@ -1023,6 +1238,8 @@ export class OrdersService {
aprovadoEm: null,
motivoRecusa: null,
idempotencyKey: null,
pesoTotal: pesoTotalErp.toFixed(3),
m3Total: m3TotalErp.toFixed(4),
itens: itemRows.map((it) => ({
id: `${idPedido}-${it.ordem}`,
idProduto: Number(it.id_produto),

View File

@@ -24,6 +24,7 @@ import {
type AlcadaDescontosResponse,
type PromocoesResponse,
type PromocoesVigentesResponse,
type AlcadaRepResponse,
type RegrasDescontoResponse,
type RegrasVigentesResponse,
type GruposProdutoResponse,
@@ -61,8 +62,13 @@ export class PoliticasController {
return this.politicas.listPromocoesVigentes();
}
@Get('alcada')
minhaAlcada(): Promise<AlcadaRepResponse> {
return this.politicas.minhaAlcada();
}
@Post('promocoes')
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ ids: number[] }> {
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
}
@@ -91,7 +97,7 @@ export class PoliticasController {
}
@Post('regras')
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> {
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ ids: number[] }> {
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
}

View File

@@ -11,6 +11,7 @@ import type {
UpdateRegraDescontoBody,
RegrasVigentesResponse,
PromocoesVigentesResponse,
AlcadaRepResponse,
GruposProdutoResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -108,26 +109,41 @@ export class PoliticasService {
};
}
async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> {
// Cria uma promoção POR ALVO (produto ou grupo). Sem alvo, cria uma linha
// genérica (comportamento anterior preservado).
async createPromocao(body: CreatePromocaoBody): Promise<{ ids: number[] }> {
this.assertManager();
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') ?? 'system';
const p = await prisma.promocao.create({
const alvos: { codProduto: string | null; grpProd: string | null }[] = [
...(body.codProdutos ?? []).map((c) => ({ codProduto: c, grpProd: null })),
...(body.grpProds ?? []).map((g) => ({ codProduto: null, grpProd: g })),
];
if (alvos.length === 0) alvos.push({ codProduto: null, grpProd: null });
const ids = await prisma.$transaction(async (tx) => {
const created: number[] = [];
for (const alvo of alvos) {
const p = await tx.promocao.create({
data: {
idEmpresa,
descricao: body.descricao,
codProduto: body.codProduto ?? null,
grpProd: body.grpProd ?? null,
codProduto: alvo.codProduto,
grpProd: alvo.grpProd,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
return { id: p.id };
created.push(p.id);
}
return created;
});
return { ids };
}
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
@@ -256,19 +272,30 @@ export class PoliticasService {
};
}
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos.
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> {
this.assertManager();
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') ?? 'system';
const r = await prisma.regraDesconto.create({
const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })),
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })),
];
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null });
const ids = await prisma.$transaction(async (tx) => {
const created: number[] = [];
for (const alvo of alvos) {
const r = await tx.regraDesconto.create({
data: {
idEmpresa,
descricao: body.descricao,
codGrupo: body.codGrupo ?? null,
codSubgrupo: body.codSubgrupo ?? null,
codGrupo: alvo.codGrupo,
codSubgrupo: alvo.codSubgrupo,
codVendedor: body.codVendedor ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
@@ -276,7 +303,11 @@ export class PoliticasService {
createdBy: userId,
},
});
return { id: r.id };
created.push(r.id);
}
return created;
});
return { ids };
}
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
@@ -315,6 +346,25 @@ export class PoliticasService {
await prisma.regraDesconto.delete({ where: { id } });
}
// Alçada do próprio usuário logado — o carrinho usa para mostrar o teto de
// desconto por item (codGrupo 0 = default; sem linha 0, o backend assume 5%).
async minhaAlcada(): Promise<AlcadaRepResponse> {
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 codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
const rows = await prisma.alcadaDesconto.findMany({
where: { codVendedor, idEmpresa },
});
return {
limites: rows.map((r) => ({
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
})),
};
}
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {

View File

@@ -0,0 +1,157 @@
import { useState } from 'react';
import { Button, Card, Flex, Popconfirm, Skeleton, Typography, Upload, message } from 'antd';
import type { UploadProps } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faImage, faTrash, faUpload } from '@fortawesome/free-solid-svg-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCompany } from '../../lib/queries/company';
import { apiFetch } from '../../lib/api-client';
const { Title, Text } = Typography;
const MAX_LOGO_BYTES = 500 * 1024; // ~500KB — logo de cabeçalho não precisa mais
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('Falha ao ler o arquivo'));
reader.readAsDataURL(file);
});
}
export function GerConfigPage() {
const { data: empresa, isLoading } = useCompany();
const qc = useQueryClient();
const [msg, msgCtx] = message.useMessage();
const [uploading, setUploading] = useState(false);
const updateLogo = useMutation({
mutationFn: (logoBase64: string | null) =>
apiFetch('/catalog/company/logo', { method: 'PUT', body: { logoBase64 } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['company'] }),
});
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
void msg.error('Use uma imagem PNG, JPG ou WebP');
return Upload.LIST_IGNORE;
}
if (file.size > MAX_LOGO_BYTES) {
void msg.error('Imagem muito grande — use um arquivo de até 500KB');
return Upload.LIST_IGNORE;
}
setUploading(true);
try {
const dataUrl = await fileToDataUrl(file);
await updateLogo.mutateAsync(dataUrl);
void msg.success('Logo atualizado — já vale para as impressões de todos os representantes');
} catch {
// erro já notificado pelo handler global do QueryClient
} finally {
setUploading(false);
}
return Upload.LIST_IGNORE; // upload manual — nada vai para action padrão
};
async function removeLogo() {
try {
await updateLogo.mutateAsync(null);
} catch {
return;
}
void msg.success('Logo removido');
}
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{msgCtx}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Configurações
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Preferências da empresa aplicadas a todos os representantes
</Text>
</Flex>
<Card
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faImage} style={{ color: 'var(--jcs-blue)' }} />
Logo da Empresa
</Flex>
}
style={{ maxWidth: 640 }}
>
{isLoading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : (
<Flex vertical gap={16}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Aparece no cabeçalho da impressão do pedido (PDF) de todos os representantes da{' '}
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? 'empresa'}. PNG com fundo
transparente fica melhor. Máx. 500KB.
</Text>
<Flex align="center" gap={24} wrap="wrap">
<div
style={{
width: 220,
height: 90,
border: '1px dashed #CBD5E1',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#F8FAFC',
overflow: 'hidden',
}}
>
{empresa?.logoBase64 ? (
<img
src={empresa.logoBase64}
alt="Logo da empresa"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Nenhum logo cadastrado
</Text>
)}
</div>
<Flex vertical gap={8}>
<Upload
accept="image/png,image/jpeg,image/webp"
showUploadList={false}
beforeUpload={beforeUpload}
>
<Button
type="primary"
icon={<FontAwesomeIcon icon={faUpload} />}
loading={uploading || updateLogo.isPending}
>
{empresa?.logoBase64 ? 'Trocar logo' : 'Enviar logo'}
</Button>
</Upload>
{empresa?.logoBase64 && (
<Popconfirm
title="Remover o logo?"
okText="Sim"
cancelText="Nao"
onConfirm={() => void removeLogo()}
>
<Button danger icon={<FontAwesomeIcon icon={faTrash} />}>
Remover
</Button>
</Popconfirm>
)}
</Flex>
</Flex>
</Flex>
)}
</Card>
</Flex>
);
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Button,
Card,
@@ -11,7 +12,9 @@ import {
Skeleton,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import { useNavigate } from '@tanstack/react-router';
import type { TableColumnsType } from 'antd';
@@ -42,6 +45,7 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente';
import { apiFetch } from '../../lib/api-client';
ChartJS.register(
CategoryScale,
@@ -55,9 +59,6 @@ ChartJS.register(
const { Title, Text } = Typography;
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
@@ -84,6 +85,21 @@ const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
</Text>
),
},
{
title: 'Novos no mês',
key: 'novos',
width: 120,
align: 'center' as const,
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
render: (_: unknown, row: PositivacaoRep) =>
row.novosClientes > 0 ? (
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{row.novosClientes}
</Tag>
) : (
<Text type="secondary"></Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
@@ -213,25 +229,31 @@ const rankingColumns: TableColumnsType<RankingRep> = [
function PositivacaoDiariaCard({
dados,
metaSalva,
periodo,
periodoLabel,
isMesAtual,
}: {
dados: PositivacaoDia[];
metaSalva: number;
periodo: Dayjs;
periodoLabel: string;
isMesAtual: boolean;
}) {
const [meta, setMeta] = useState<number>(() => {
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
return Number.isFinite(saved) && saved > 0 ? saved : 0;
});
// Meta salva no servidor (config_empresa) — vale em qualquer dispositivo
const [meta, setMeta] = useState<number>(metaSalva);
useEffect(() => setMeta(metaSalva), [metaSalva]);
const qc = useQueryClient();
const [msg, msgCtx] = message.useMessage();
function changeMeta(v: number | null) {
const val = v ?? 0;
setMeta(val);
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
}
const salvarMeta = useMutation({
mutationFn: (m: number) =>
apiFetch('/dashboard/manager/meta-positivacao-dia', { method: 'PUT', body: { meta: m } }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
void msg.success('Meta salva');
},
});
// Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores
const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
@@ -318,13 +340,23 @@ function PositivacaoDiariaCard({
min={0}
size="small"
value={meta > 0 ? meta : undefined}
onChange={changeMeta}
onChange={(v) => setMeta(v ?? 0)}
placeholder="—"
style={{ width: 80 }}
/>
<Button
type="primary"
size="small"
disabled={meta === metaSalva}
loading={salvarMeta.isPending}
onClick={() => salvarMeta.mutate(meta)}
>
Salvar
</Button>
</Flex>
}
>
{msgCtx}
<div style={{ height: 280 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
@@ -629,6 +661,7 @@ export function GerPainel() {
{/* Positivação Diária */}
<PositivacaoDiariaCard
dados={positivacaoDiaria}
metaSalva={data.metaPositivacaoDia ?? 0}
periodo={periodo}
periodoLabel={periodoLabel}
isMesAtual={isMesAtual}
@@ -643,9 +676,20 @@ export function GerPainel() {
</Space>
}
extra={
<Flex align="center" gap={12}>
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) > 0 && (
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0)} novo
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''}{' '}
cliente
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''} no
período
</Tag>
)}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
</Text>
</Flex>
}
>
<Table<PositivacaoRep>

View File

@@ -11,6 +11,7 @@ import {
Select,
Skeleton,
Space,
Switch,
Table,
Tabs,
Tag,
@@ -44,6 +45,62 @@ import {
useUpdateRegraDesconto,
useDeleteRegraDesconto,
} from '../../lib/queries/gerente';
import { useCatalog } from '../../lib/queries/catalog';
// ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
function ProdutoSelect({
multiple,
value,
onChange,
}: {
multiple?: boolean;
value?: string[] | string;
onChange?: (v: string[] | string) => void;
}) {
const [q, setQ] = useState('');
const { data, isFetching } = useCatalog({ q: q || undefined, limit: 20 });
// Guarda os rótulos dos já selecionados — a lista de opções muda a cada busca
const [labels, setLabels] = useState<Map<string, string>>(new Map());
const opts = (data?.data ?? []).map((p) => ({
value: p.codigo.trim(),
label: `${p.codigo.trim()}${p.descricao.trim()}`,
}));
const selecionados = multiple
? ((value as string[] | undefined) ?? [])
: value
? [value as string]
: [];
const merged = [...opts];
for (const v of selecionados) {
if (!merged.some((o) => o.value === v)) merged.push({ value: v, label: labels.get(v) ?? v });
}
return (
<Select
mode={multiple ? 'multiple' : undefined}
showSearch
allowClear
filterOption={false}
onSearch={setQ}
loading={isFetching}
options={merged}
value={value}
onChange={(vals) => {
setLabels((prev) => {
const next = new Map(prev);
for (const o of opts) next.set(o.value, o.label);
return next;
});
setQ('');
onChange?.(vals as string[] | string);
}}
placeholder="Busque por código ou descrição"
notFoundContent={q && !isFetching ? 'Nenhum produto encontrado' : null}
/>
);
}
const { Title, Text } = Typography;
@@ -170,14 +227,18 @@ function TabDescontos() {
interface PromocaoForm {
descricao: string;
codProdutos?: string[];
grpProds?: string[];
codProduto?: string;
grpProd?: string;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean;
}
function TabPromocoes() {
const { data, isLoading } = usePromocoes();
const { data: gruposData } = useGruposProduto();
const createMutation = useCreatePromocao();
const updateMutation = useUpdatePromocao();
const deleteMutation = useDeletePromocao();
@@ -186,6 +247,18 @@ function TabPromocoes() {
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
const [msg, msgCtx] = message.useMessage();
// Grupos com nome+código no rótulo — busca funciona pelos dois
const grupoOptions = [
...new Map(
(gruposData?.grupos ?? [])
.filter((g) => g.codGrupo != null)
.map((g) => [
g.codGrupo,
{ value: String(g.codGrupo), label: `${g.codGrupo}${g.grupo ?? 'Sem nome'}` },
]),
).values(),
];
function openCreate() {
setEditTarget(null);
form.resetFields();
@@ -200,27 +273,49 @@ function TabPromocoes() {
grpProd: p.grpProd ?? undefined,
descPct: p.descPct,
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
ativa: p.ativa,
});
setModalOpen(true);
}
async function toggleAtiva(p: Promocao, ativa: boolean) {
try {
await updateMutation.mutateAsync({ id: p.id, body: { ativa } });
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
void msg.success(ativa ? 'Promocao ativada' : 'Promocao desativada');
}
async function handleSubmit(values: PromocaoForm) {
const [inicio, fim] = values.periodo;
const body = {
try {
if (editTarget) {
await updateMutation.mutateAsync({
id: editTarget.id,
body: {
descricao: values.descricao,
codProduto: values.codProduto || undefined,
grpProd: values.grpProd || undefined,
codProduto: values.codProduto ?? null,
grpProd: values.grpProd ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
ativa: values.ativa ?? true,
},
});
void msg.success('Promocao atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Promocao criada');
const res = (await createMutation.mutateAsync({
descricao: values.descricao,
codProdutos: values.codProdutos?.length ? values.codProdutos : undefined,
grpProds: values.grpProds?.length ? values.grpProds : undefined,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
})) as { ids: number[] };
void msg.success(
res.ids.length > 1 ? `${res.ids.length} promocoes criadas` : 'Promocao criada',
);
}
} catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
@@ -280,6 +375,19 @@ function TabPromocoes() {
return <Tag color="green">Ativa</Tag>;
},
},
{
title: 'Ativa',
width: 70,
align: 'center' as const,
render: (_: unknown, row: Promocao) => (
<Switch
size="small"
checked={row.ativa}
loading={updateMutation.isPending}
onChange={(checked) => void toggleAtiva(row, checked)}
/>
),
},
{
title: '',
width: 100,
@@ -337,7 +445,8 @@ function TabPromocoes() {
onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@@ -349,14 +458,49 @@ function TabPromocoes() {
<Input placeholder="Ex.: Promocao de inverno" />
</Form.Item>
{editTarget ? (
<Flex gap={16}>
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
<Form.Item name="codProduto" label="Produto" style={{ flex: 1 }}>
<ProdutoSelect />
</Form.Item>
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
<Select
options={grupoOptions}
placeholder="Opcional"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
<Switch />
</Form.Item>
</Flex>
) : (
<>
<Form.Item
name="codProdutos"
label="Produtos"
extra="Busque por código ou descrição e selecione quantos quiser — será criada uma promoção por produto"
>
<ProdutoSelect multiple />
</Form.Item>
<Form.Item
name="grpProds"
label="Grupos de produto"
extra="Busque por nome ou código — uma promoção por grupo selecionado"
>
<Select
mode="multiple"
options={grupoOptions}
placeholder="Busque por nome ou código do grupo"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</>
)}
<Form.Item
name="descPct"
@@ -388,10 +532,13 @@ function TabPromocoes() {
interface RegraForm {
descricao: string;
codVendedor?: number;
codGrupos?: number[];
codSubgrupos?: number[];
codGrupo?: number;
codSubgrupo?: number;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean;
}
function TabRegras() {
@@ -406,27 +553,37 @@ function TabRegras() {
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
const [msg, msgCtx] = message.useMessage();
const grupoSelecionado = Form.useWatch('codGrupo', form);
const gruposSelecionados = Form.useWatch('codGrupos', form);
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
value: r.codVendedor,
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
}));
// Rótulos com código + nome — a busca do select encontra pelos dois.
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
const filtroGrupos = new Set<number>([
...(grupoSelecionado != null ? [grupoSelecionado] : []),
...(gruposSelecionados ?? []),
]);
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
const grupoMap = new Map<number, { value: number; label: string }>();
const subgrupoMap = new Map<number, { value: number; label: string }>();
for (const g of grupoRows) {
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
grupoMap.set(g.codGrupo, {
value: g.codGrupo,
label: `${g.codGrupo}${g.grupo ?? 'Sem nome'}`,
});
}
if (
g.codSubgrupo != null &&
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
(filtroGrupos.size === 0 || (g.codGrupo != null && filtroGrupos.has(g.codGrupo))) &&
!subgrupoMap.has(g.codSubgrupo)
) {
subgrupoMap.set(g.codSubgrupo, {
value: g.codSubgrupo,
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
label: `${g.codSubgrupo} ${g.subgrupo ?? 'Sem nome'}`,
});
}
}
@@ -448,13 +605,27 @@ function TabRegras() {
codSubgrupo: r.codSubgrupo ?? undefined,
descPct: r.descPct,
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
ativa: r.ativa,
});
setModalOpen(true);
}
async function toggleAtiva(r: RegraDesconto, ativa: boolean) {
try {
await updateMutation.mutateAsync({ id: r.id, body: { ativa } });
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
void msg.success(ativa ? 'Regra ativada' : 'Regra desativada');
}
async function handleSubmit(values: RegraForm) {
const [inicio, fim] = values.periodo;
const body = {
try {
if (editTarget) {
await updateMutation.mutateAsync({
id: editTarget.id,
body: {
descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null,
@@ -462,14 +633,21 @@ function TabRegras() {
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
ativa: values.ativa ?? true,
},
});
void msg.success('Regra atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Regra criada');
const res = (await createMutation.mutateAsync({
descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
})) as { ids: number[] };
void msg.success(res.ids.length > 1 ? `${res.ids.length} regras criadas` : 'Regra criada');
}
} catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
@@ -541,6 +719,19 @@ function TabRegras() {
return <Tag color="green">Ativa</Tag>;
},
},
{
title: 'Ativa',
width: 70,
align: 'center' as const,
render: (_: unknown, row: RegraDesconto) => (
<Switch
size="small"
checked={row.ativa}
loading={updateMutation.isPending}
onChange={(checked) => void toggleAtiva(row, checked)}
/>
),
},
{
title: '',
width: 100,
@@ -602,7 +793,8 @@ function TabRegras() {
onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@@ -624,6 +816,7 @@ function TabRegras() {
/>
</Form.Item>
{editTarget ? (
<Flex gap={16}>
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
<Select
@@ -644,7 +837,42 @@ function TabRegras() {
optionFilterProp="label"
/>
</Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
<Switch />
</Form.Item>
</Flex>
) : (
<>
<Form.Item
name="codGrupos"
label="Grupos de produto"
extra="Busque por nome ou código e selecione quantos quiser — uma regra por grupo"
>
<Select
mode="multiple"
options={grupoOptions}
placeholder="Todos os grupos"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item
name="codSubgrupos"
label="Subgrupos"
extra="Uma regra por subgrupo selecionado (a lista filtra pelos grupos escolhidos)"
>
<Select
mode="multiple"
options={subgrupoOptions}
placeholder="Todos os subgrupos"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</>
)}
<Form.Item
name="descPct"

View File

@@ -161,7 +161,10 @@ export function CatalogPage() {
}}
options={pautas?.map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
label:
p.qtdReps != null
? `${p.codigo}${p.descricao} · ${p.qtdReps} rep${p.qtdReps !== 1 ? 's' : ''}`
: `${p.codigo}${p.descricao}`,
}))}
/>
</Space>

View File

@@ -29,9 +29,12 @@ import {
CheckCircleOutlined,
CloseOutlined,
DeleteOutlined,
DownOutlined,
HistoryOutlined,
PlusOutlined,
SearchOutlined,
ShoppingCartOutlined,
UpOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate, useSearch } from '@tanstack/react-router';
@@ -41,16 +44,16 @@ import type {
FormaPagamento,
Pauta,
ProdutoSummary,
RegraVigente,
TopProduto,
} from '@sar/api-interface';
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
import { useOrderDetail } from '../../lib/queries/orders';
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
import { useRegrasVigentes } from '../../lib/queries/politicas';
import { useCondicoesComerciais } from '../../lib/condicoes-comerciais';
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
import { apiFetch } from '../../lib/api-client';
import { enqueueOrder } from '../../lib/offline/order-queue';
import { randomUUID } from '../../lib/uuid';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -67,9 +70,12 @@ type CartItem = {
precoUnitario: number;
descontoPerc: number;
loteMulVenda: number | null;
// Grupo/subgrupo do produto — usados para calcular o teto de desconto do item
codGrupo: number | null;
codSubgrupo: number | null;
};
type SearchParams = { clientId?: string };
type SearchParams = { clientId?: string; editar?: string };
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -630,17 +636,20 @@ function CatalogBrowserModal({
function MobileCartItem({
item,
teto,
onQtyChange,
onDiscChange,
onRemove,
}: {
item: CartItem;
teto: number;
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void;
}) {
const lote = item.loteMulVenda ?? 1;
const invalid = lote > 1 && item.qtd % lote !== 0;
const acimaTeto = item.descontoPerc > teto + 0.001;
return (
<div
@@ -712,6 +721,14 @@ function MobileCartItem({
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
Desc%
</Text>
<Tooltip
title={
acimaTeto
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
: `Teto do item: ${teto}%`
}
color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber
min={0}
max={100}
@@ -720,8 +737,13 @@ function MobileCartItem({
size="small"
style={{ width: 88 }}
addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(item.key, n ?? 0)}
/>
</Tooltip>
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
</div>
</div>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
@@ -749,7 +771,8 @@ function ClientHistoryPanel({
}) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const [collapsed, setCollapsed] = useState(false);
// Sempre fecha ao abrir a tela — o banner chamativo convida o rep a expandir
const [collapsed, setCollapsed] = useState(true);
const [qtys, setQtys] = useState<Record<number, number>>({});
const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15);
@@ -910,40 +933,61 @@ function ClientHistoryPanel({
return (
<div style={{ marginTop: 16 }}>
{/* Banner âmbar de destaque — fechado por padrão, convida o rep a abrir */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
gap: 10,
padding: '10px 14px',
background: '#FFF7E6',
border: '1px solid #FFD591',
borderRadius: collapsed ? 8 : '8px 8px 0 0',
cursor: 'pointer',
userSelect: 'none',
}}
onClick={() => setCollapsed((c) => !c)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<HistoryOutlined style={{ color: '#D46B08', fontSize: 18, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<Text
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#64748B',
}}
strong
style={{ color: '#873800', fontSize: 13, display: 'block', lineHeight: 1.3 }}
>
Comprados anteriormente · {produtos.length} produto{produtos.length !== 1 ? 's' : ''}
Este cliente comprou {produtos.length} produto{produtos.length !== 1 ? 's' : ''}{' '}
com você
</Text>
<Button type="link" size="small" style={{ padding: 0, fontSize: 12 }}>
{collapsed ? 'exibir' : 'ocultar'}
<Text style={{ color: '#AD6800', fontSize: 11 }}>
Veja o histórico e repita itens no pedido com um clique
</Text>
</div>
</div>
<Button
size="small"
style={{
borderColor: '#FFD591',
color: '#D46B08',
background: '#fff',
fontWeight: 600,
flexShrink: 0,
}}
icon={collapsed ? <DownOutlined /> : <UpOutlined />}
iconPosition="end"
>
{collapsed ? 'Ver produtos' : 'Ocultar'}
</Button>
</div>
{!collapsed && (
<div
style={{
border: '1px solid #EBF0F5',
borderRadius: 8,
border: '1px solid #FFD591',
borderTop: 'none',
borderRadius: '0 0 8px 8px',
overflow: 'hidden',
background: '#FAFBFC',
background: '#FFFDF7',
}}
>
<Table<TopProduto>
@@ -970,11 +1014,13 @@ function ClientHistoryPanel({
function OrderItemsTable({
items,
tetoDe,
onQtyChange,
onDiscChange,
onRemove,
}: {
items: CartItem[];
tetoDe: (item: CartItem) => number;
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void;
@@ -1015,6 +1061,7 @@ function OrderItemsTable({
<MobileCartItem
key={item.key}
item={item}
teto={tetoDe(item)}
onQtyChange={onQtyChange}
onDiscChange={onDiscChange}
onRemove={onRemove}
@@ -1091,8 +1138,20 @@ function OrderItemsTable({
{
title: 'Desc %',
dataIndex: 'descontoPerc',
width: 100,
render: (v: number, row: CartItem) => (
width: 110,
render: (v: number, row: CartItem) => {
const teto = tetoDe(row);
const acimaTeto = v > teto + 0.001;
return (
<div>
<Tooltip
title={
acimaTeto
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
: `Teto do item: ${teto}%`
}
color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber
min={0}
max={100}
@@ -1101,9 +1160,16 @@ function OrderItemsTable({
size="small"
style={{ width: 76 }}
addonAfter="%"
status={acimaTeto ? 'error' : undefined}
onChange={(n) => onDiscChange(row.key, n ?? 0)}
/>
),
</Tooltip>
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
</div>
</div>
);
},
},
{
title: 'Total',
@@ -1251,11 +1317,16 @@ function OrderSummaryFooter({
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
export function NewOrderPage() {
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
const { clientId: clientIdParam, editar: editarId } = useSearch({
strict: false,
}) as SearchParams;
const navigate = useNavigate();
const qc = useQueryClient();
const { message } = App.useApp();
// ── Modo edição: orçamento (situa 0) carregado para alteração ──
const { data: editOrder } = useOrderDetail(editarId);
// ── Dados do cliente ──
const [clientSearch, setClientSearch] = useState('');
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
@@ -1267,8 +1338,11 @@ export function NewOrderPage() {
const { data: selectedClientDetail } = useClientDetail(
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
);
const clientDetail = selectedClientDetail ?? preloadedClient ?? null;
const effectiveClient = selectedClient ?? preloadedClient ?? null;
const { data: editClient } = useClientDetail(
editarId && editOrder ? editOrder.idCliente : undefined,
);
const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null;
const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null;
// ── Campos comerciais ──
const { data: pautas = [] } = usePautas();
@@ -1293,6 +1367,39 @@ export function NewOrderPage() {
const [error, setError] = useState<string | null>(null);
const [catalogOpen, setCatalogOpen] = useState(false);
// Pré-carrega o formulário quando entra em modo edição (uma vez só)
const [editLoaded, setEditLoaded] = useState(false);
useEffect(() => {
if (!editarId || !editOrder || editLoaded) return;
setEditLoaded(true);
if (editOrder.situa !== 0) {
setError('Este pedido já foi transmitido e não pode mais ser editado.');
return;
}
setCart(
editOrder.itens.map((it) => ({
key: String(it.idProduto),
idProduto: it.idProduto,
codProduto: it.codProduto ?? '',
descProduto: it.descProduto ?? '',
unidade: '',
qtd: Number(it.qtd),
precoUnitario: Number(it.precoUnitario),
descontoPerc: Number(it.descontoPerc),
loteMulVenda: null,
codGrupo: it.codGrupo ?? null,
codSubgrupo: it.codSubgrupo ?? null,
})),
);
setIdPauta(editOrder.idPauta ?? undefined);
setCodFormapag(editOrder.codFormapag ?? undefined);
setObs(editOrder.obs ?? '');
if (editOrder.endEntrega) {
setEndTipo('outro');
setEndCustom(editOrder.endEntrega);
}
}, [editarId, editOrder, editLoaded]);
// Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
const clientEndStr = (() => {
if (!clientDetail) return '';
@@ -1306,18 +1413,29 @@ export function NewOrderPage() {
})();
const hasClientEnd = !!clientEndStr;
// Teto de desconto por item (alçada + promoção/regra) — aviso em tempo real
const tetoDesconto = useTetoDesconto();
const tetoDe = (it: CartItem) =>
tetoDesconto({ codigo: it.codProduto, codGrupo: it.codGrupo, codSubgrupo: it.codSubgrupo });
const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
const loteErrors = cart.filter((it) => {
const lote = it.loteMulVenda ?? 1;
return lote > 1 && it.qtd % lote !== 0;
});
const canSubmit = !!effectiveClient && cart.length > 0 && loteErrors.length === 0;
// Desconto acima do teto bloqueia o Concluir — o backend recusa a criação
// do orçamento de qualquer forma; aqui a mensagem chega antes do clique.
const tetoErrors = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001);
const canSubmit =
!!effectiveClient && cart.length > 0 && loteErrors.length === 0 && tetoErrors.length === 0;
const missingReason = !effectiveClient
? 'Selecione um cliente para continuar'
: cart.length === 0
? 'Adicione ao menos um produto ao pedido'
: loteErrors.length > 0
? 'Corrija as quantidades com lote múltiplo'
: tetoErrors.length > 0
? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}`
: undefined;
function clearClient() {
@@ -1325,24 +1443,10 @@ export function NewOrderPage() {
setClientSearch('');
}
// ── Regras de desconto vigentes (criadas pelo gerente para este rep) ──
const { data: regrasData } = useRegrasVigentes();
// Maior desconto liberado por regra vigente que casa com o grupo/subgrupo do
// produto. Regra sem grupo/subgrupo vale para qualquer produto. Pré-aplicado
// ao adicionar o item; o rep pode ajustar dentro da alçada.
const regraDescontoPct = (codGrupo: number | null, codSubgrupo: number | null): number => {
const regras: RegraVigente[] = regrasData?.regras ?? [];
return regras
.filter(
(r) =>
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)),
)
.reduce((max, r) => Math.max(max, r.descPct), 0);
};
// ── Handlers do carrinho ──
// Item entra SEM desconto mesmo quando há promoção/regra vigente (selo
// "Condição comercial" avisa o rep): a proposta é carta de negociação — o rep
// concede só se o cliente pedir, e a alçada no backend aceita até o % da regra.
const addToCart = (p: ProdutoSummary, qty?: number) => {
const lote = p.loteMulVenda ?? 1;
const finalQty = qty ?? lote;
@@ -1363,8 +1467,10 @@ export function NewOrderPage() {
unidade: p.unidade ?? '',
qtd: finalQty,
precoUnitario: Number(p.vlPreco1),
descontoPerc: regraDescontoPct(p.codGrupo, p.codSubgrupo),
descontoPerc: 0,
loteMulVenda: p.loteMulVenda,
codGrupo: p.codGrupo,
codSubgrupo: p.codSubgrupo,
},
];
});
@@ -1386,9 +1492,11 @@ export function NewOrderPage() {
unidade: p.unidade ?? '',
qtd: qty,
precoUnitario: p.ultimoPreco,
// TopProduto não traz grupo/subgrupo — aplica só regra sem restrição
descontoPerc: regraDescontoPct(null, null),
descontoPerc: 0,
loteMulVenda: null,
// TopProduto não traz grupo/subgrupo — teto considera só alçada default
codGrupo: null,
codSubgrupo: null,
},
];
});
@@ -1416,6 +1524,13 @@ export function NewOrderPage() {
`Quantidade inválida: ${invalidos.map((it) => `${it.codProduto.trim()} (lote múltiplo: ${it.loteMulVenda})`).join(', ')}`,
);
}
// Cobre também o caminho offline (enqueueOrder não passa pelo backend)
const acimaTeto = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001);
if (acimaTeto.length > 0) {
throw new Error(
`Desconto acima do teto: ${acimaTeto.map((it) => `${it.codProduto.trim()} (máx. ${tetoDe(it)}%)`).join(', ')}`,
);
}
const obsCompleta = [
contato ? `Contato: ${contato}` : null,
@@ -1435,7 +1550,7 @@ export function NewOrderPage() {
descontoPerc: 0,
obs: obsCompleta || undefined,
endEntrega: endEntregaValue,
idempotencyKey: crypto.randomUUID(),
idempotencyKey: editarId ? undefined : randomUUID(),
itens: cart.map((it, idx) => ({
idProduto: it.idProduto,
codProduto: it.codProduto,
@@ -1447,6 +1562,16 @@ export function NewOrderPage() {
})),
};
// Edição altera o orçamento existente — requer conexão
if (editarId) {
if (!navigator.onLine) {
throw new Error('Edição de orçamento requer conexão. Tente novamente online.');
}
return apiFetch(`/orders/${editarId}`, { method: 'PATCH', body }) as Promise<{
id: string;
}>;
}
if (!navigator.onLine) {
await enqueueOrder(body, effectiveClient.nome);
window.dispatchEvent(new CustomEvent('sar:order-queued'));
@@ -1458,6 +1583,11 @@ export function NewOrderPage() {
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients'] });
if (editarId && created) {
message.success('Orçamento atualizado');
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
return;
}
if (!created) {
setError(null);
setCart([]);
@@ -1475,7 +1605,7 @@ export function NewOrderPage() {
}
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
},
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao salvar pedido'),
});
return (
@@ -1490,7 +1620,9 @@ export function NewOrderPage() {
}}
>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Lançamento de Pedido
{editarId
? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}`
: 'Lançamento de Pedido'}
</Title>
<Button
icon={<ArrowLeftOutlined />}
@@ -1700,6 +1832,7 @@ export function NewOrderPage() {
<OrderItemsTable
items={cart}
tetoDe={tetoDe}
onQtyChange={setQty}
onDiscChange={setDisc}
onRemove={removeItem}

View File

@@ -145,60 +145,71 @@ export function OrderPrintPage() {
color: INK,
}}
>
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
{/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '22px 28px',
borderTop: `5px solid ${BLUE}`,
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
borderBottom: `1px solid ${LINE}`,
alignItems: 'center',
gap: 16,
padding: '12px 28px',
borderTop: `4px solid ${BLUE}`,
borderBottom: `2px solid ${BLUE}`,
}}
>
<div style={{ maxWidth: 460 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
{empresa?.logoBase64 && (
<img
src={empresa.logoBase64}
alt="Logo"
style={{ maxHeight: 52, maxWidth: 140, objectFit: 'contain', flexShrink: 0 }}
/>
)}
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 800, color: BLUE, lineHeight: 1.15 }}>
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
</div>
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
<div style={{ fontSize: 8.5, color: MUTED, marginTop: 2, lineHeight: 1.45 }}>
{empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && (
<>{empresa.razaoSocial} · </>
)}
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
{enderecoEmp && <div>{enderecoEmp}</div>}
{cidadeEmp && <div>{cidadeEmp}</div>}
<br />
{[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')}
{(empresa?.telefone || empresa?.email) && (
<div>
<>
<br />
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
{empresa?.telefone && empresa?.email && <> · </>}
{empresa?.email}
</div>
</>
)}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 9, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
PEDIDO
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
{order.numPedSar}
</div>
<div
<div style={{ fontSize: 9.5, color: MUTED, marginTop: 3 }}>
<span
style={{
display: 'inline-block',
marginTop: 6,
padding: '2px 10px',
padding: '1px 8px',
borderRadius: 20,
background: `${BLUE}12`,
color: BLUE,
fontSize: 10.5,
fontWeight: 700,
marginRight: 6,
}}
>
{SITUA_LABEL[order.situa] ?? String(order.situa)}
</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
Emissão: {dateBR(order.dtPedido)}
</span>
{dateBR(order.dtPedido)}
</div>
</div>
</div>
@@ -370,7 +381,30 @@ export function OrderPrintPage() {
</div>
{/* ── Totais ──────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end',
gap: 16,
padding: '14px 28px 4px',
}}
>
{/* Peso e cubagem do pedido (logística/frete) */}
<div style={{ fontSize: 10.5, color: MUTED, lineHeight: 1.8 }}>
{Number(order.pesoTotal ?? 0) > 0 && (
<div>
<strong style={{ color: INK }}>Peso total:</strong>{' '}
{Number(order.pesoTotal).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} kg
</div>
)}
{Number(order.m3Total ?? 0) > 0 && (
<div>
<strong style={{ color: INK }}>Volume:</strong>{' '}
{Number(order.m3Total).toLocaleString('pt-BR', { maximumFractionDigits: 3 })} m³
</div>
)}
</div>
<div style={{ width: 300 }}>
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}

View File

@@ -6,6 +6,8 @@ import {
Card,
Col,
DatePicker,
Form,
Input,
Modal,
Dropdown,
Grid,
@@ -28,6 +30,7 @@ import {
CloseCircleOutlined,
CopyOutlined,
DollarOutlined,
EditOutlined,
EllipsisOutlined,
EyeOutlined,
FilePdfOutlined,
@@ -244,6 +247,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
const MOTIVOS_CANCELAMENTO = [
'Cliente desistiu',
'Preço',
'Prazo de entrega',
'Erro de lançamento',
'Outro',
];
function OrderActionsMenu({
order,
onView,
@@ -253,18 +264,24 @@ function OrderActionsMenu({
}) {
const navigate = useNavigate();
const qc = useQueryClient();
const { modal, message: msg } = App.useApp();
const { message: msg } = App.useApp();
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelForm] = Form.useForm<{ motivo: string; descricao?: string }>();
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
mutationFn: (body: { motivo: string; descricao?: string }) =>
apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH', body }),
onSuccess: () => {
void msg.success('Orçamento cancelado.');
setCancelOpen(false);
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
});
// Orçamento (situa 0) pode ser editado e cancelado; transmitido, não.
const canCancel = order.situa === 0 && order.fonte !== 'erp';
const canEdit = canCancel;
const items: MenuProps['items'] = [
{
@@ -273,6 +290,16 @@ function OrderActionsMenu({
label: 'Ver detalhes',
onClick: () => onView(order.id),
},
{
key: 'edit',
icon: <EditOutlined />,
label: 'Editar orçamento',
disabled: !canEdit,
onClick: () => {
if (!canEdit) return;
void navigate({ to: '/pedidos/novo', search: { editar: order.id } });
},
},
{
key: 'duplicate',
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
@@ -305,22 +332,59 @@ function OrderActionsMenu({
disabled: !canCancel,
onClick: () => {
if (!canCancel) return;
void modal.confirm({
title: 'Cancelar orçamento?',
content: `O pedido ${order.numPedSar} será marcado como cancelado. Esta ação não pode ser desfeita.`,
okText: 'Cancelar pedido',
okButtonProps: { danger: true },
cancelText: 'Voltar',
onOk: () => cancelMutation.mutate(),
});
cancelForm.resetFields();
setCancelOpen(true);
},
},
];
return (
// stopPropagation: o clique nos "..." não pode acionar o clique da LINHA
// (que abre o detalhe) — o menu abre sozinho e só a opção escolhida age.
<span onClick={(e) => e.stopPropagation()}>
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
<Button type="text" icon={<EllipsisOutlined />} size="small" />
</Dropdown>
<Modal
title={`Cancelar orçamento ${order.numPedSar}?`}
open={cancelOpen}
onCancel={() => setCancelOpen(false)}
okText="Cancelar pedido"
okButtonProps={{ danger: true }}
cancelText="Voltar"
confirmLoading={cancelMutation.isPending}
onOk={() =>
void cancelForm.validateFields().then((values) => cancelMutation.mutate(values))
}
centered
width={480}
>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
O pedido será marcado como cancelado. Esta ação não pode ser desfeita.
</Typography.Text>
<Form form={cancelForm} layout="vertical">
<Form.Item
name="motivo"
label="Motivo do cancelamento"
rules={[{ required: true, message: 'Informe o motivo' }]}
>
<Select
placeholder="Selecione o motivo"
options={MOTIVOS_CANCELAMENTO.map((m) => ({ value: m, label: m }))}
/>
</Form.Item>
<Form.Item name="descricao" label="Descrição (opcional)">
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="Detalhe o que aconteceu, se quiser"
/>
</Form.Item>
</Form>
</Modal>
</span>
);
}

View File

@@ -12,6 +12,7 @@ import {
faFileInvoiceDollar,
faTags,
faHeadset,
faGear,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store';
@@ -91,6 +92,11 @@ const GERENTE_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
label: 'Clientes',
},
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
@@ -102,6 +108,11 @@ const GERENTE_ITEMS: ItemType[] = [
label: 'Políticas Comerciais',
},
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
{
key: '/ger/config',
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
label: 'Configurações',
},
];
function getItems(role: string): ItemType[] {

View File

@@ -1,5 +1,5 @@
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
import { usePromocoesVigentes, useRegrasVigentes, useMinhaAlcada } from './queries/politicas';
// Condição comercial vigente que alcança um produto: promoção (por produto ou
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
@@ -62,3 +62,17 @@ export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[]
const { data: regras } = useRegrasVigentes();
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
}
// Teto de desconto do item — espelha assertAlcadaItens do backend: vale o MAIOR
// entre a alçada do grupo (codGrupo 0 = default; sem linha, 5%) e a melhor
// promoção/regra vigente que alcança o produto.
export function useTetoDesconto(): (p: ProdutoRef) => number {
const condicoesDe = useCondicoesComerciais();
const { data: alcada } = useMinhaAlcada();
return (p) => {
const limites = new Map((alcada?.limites ?? []).map((l) => [l.codGrupo, l.limitePerc]));
const base = (p.codGrupo != null ? limites.get(p.codGrupo) : undefined) ?? limites.get(0) ?? 5;
const cond = condicoesDe(p).reduce((max, c) => Math.max(max, c.descPct), 0);
return Math.max(base, cond);
};
}

View File

@@ -5,6 +5,7 @@
import type { CreatePedido } from '@sar/api-interface';
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
import { randomUUID } from '../uuid';
export interface PendingOrder {
idempotencyKey: string; // keyPath do IndexedDB
@@ -23,7 +24,7 @@ export async function enqueueOrder(
payload: CreatePedido,
clienteNome: string,
): Promise<PendingOrder> {
const key = payload.idempotencyKey ?? crypto.randomUUID();
const key = payload.idempotencyKey ?? randomUUID();
const order: PendingOrder = {
idempotencyKey: key,
payload: { ...payload, idempotencyKey: key },

View File

@@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query';
import {
RegrasVigentesResponseSchema,
PromocoesVigentesResponseSchema,
AlcadaRepResponseSchema,
type RegrasVigentesResponse,
type PromocoesVigentesResponse,
type AlcadaRepResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
@@ -20,6 +22,18 @@ export function useRegrasVigentes() {
});
}
// Alçada do usuário logado — teto de desconto por item no carrinho
export function useMinhaAlcada() {
return useQuery<AlcadaRepResponse>({
queryKey: ['politicas', 'minha-alcada'],
queryFn: async () => {
const raw = await apiFetch('/politicas/alcada');
return AlcadaRepResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
export function usePromocoesVigentes() {
return useQuery<PromocoesVigentesResponse>({

View File

@@ -27,6 +27,7 @@ import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
import { GerConfigPage } from '../cockpits/ger/GerConfigPage';
import { authStore } from './auth-store';
function getRoleFromToken(): string {
@@ -207,6 +208,12 @@ const gerChamadosRoute = createRoute({
component: GerChamadosPage,
});
const gerConfigRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/config',
component: GerConfigPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
@@ -228,6 +235,7 @@ const routeTree = rootRoute.addChildren([
politicasRoute,
chamadosRoute,
gerChamadosRoute,
gerConfigRoute,
]);
export const router = createRouter({

12
apps/web/src/lib/uuid.ts Normal file
View File

@@ -0,0 +1,12 @@
// crypto.randomUUID só existe em contexto seguro (HTTPS/localhost). Acessando o
// dev server pelo IP da rede (http://192.168...) ela é undefined — fallback gera
// UUID v4 com crypto.getRandomValues, disponível em qualquer contexto.
export function randomUUID(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // versão 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variante RFC 4122
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

View File

@@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({
cep: z.string().nullable(),
telefone: z.string().nullable(),
email: z.string().nullable(),
// Logo (data URL) exibido no cabeçalho da impressão — sar.config_empresa
logoBase64: z.string().nullable().optional(),
});
export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;
// Upload do logo pelo gerente — data URL de imagem; null remove o logo.
// ~700KB de base64 = imagem de ~500KB, suficiente para logo de cabeçalho.
export const UpdateLogoBodySchema = z.object({
logoBase64: z
.string()
.max(700_000)
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=]+$/)
.nullable(),
});
export type UpdateLogoBody = z.infer<typeof UpdateLogoBodySchema>;

View File

@@ -85,7 +85,10 @@ export const SupervisorDashboardSchema = z.object({
});
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
// caiu no periodo selecionado
export const PositivacaoRepSchema = z.object({
novosClientes: z.number().int().default(0),
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
totalClientes: z.number().int(),
@@ -122,6 +125,14 @@ export const ManagerDashboardSchema = z.object({
rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
// Meta diária de positivação salva pelo gerente (config_empresa)
metaPositivacaoDia: z.number().int().nullable().optional(),
syncedAt: z.iso.datetime(),
});
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
// Salvar a meta diária de positivação (0 limpa a meta)
export const SaveMetaPositivacaoBodySchema = z.object({
meta: z.number().int().min(0).max(100000),
});
export type SaveMetaPositivacaoBody = z.infer<typeof SaveMetaPositivacaoBodySchema>;

View File

@@ -40,6 +40,10 @@ export const PedidoItemSchema = z.object({
precoUnitario: z.string(),
descontoPerc: z.string(),
total: z.string(),
// Grupo/subgrupo do produto (vw_produtos) — edição do orçamento usa para o
// teto de desconto. Ausente em pedidos ERP.
codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().optional(),
});
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
@@ -89,6 +93,12 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
pedFlex: z.string(),
formaPagamento: z.string().nullable().optional(),
endEntrega: z.string().nullable().optional(),
// Pauta/forma de pagamento do pedido SAR — edição do orçamento pré-carrega
idPauta: z.number().int().nullable().optional(),
codFormapag: z.number().int().nullable().optional(),
// Peso total (kg) e cubagem (m3) — qtd x peso_liquido/vol_m3 de vw_produtos
pesoTotal: z.string().optional(),
m3Total: z.string().optional(),
aprovadoPor: z.number().int().nullable(),
aprovadoEm: z.iso.datetime().nullable(),
motivoRecusa: z.string().nullable(),
@@ -149,6 +159,17 @@ export const CreatePedidoSchema = z.object({
});
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
// Edição de orçamento (situa 0): mesmo shape da criação, sem idempotencyKey
export const UpdatePedidoSchema = CreatePedidoSchema.omit({ idempotencyKey: true });
export type UpdatePedido = z.infer<typeof UpdatePedidoSchema>;
// Cancelamento com motivo obrigatório + descrição livre opcional
export const CancelPedidoSchema = z.object({
motivo: z.string().min(1).max(60),
descricao: z.string().max(500).optional(),
});
export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
export const AprovarPedidoSchema = z.object({
descontoPerc: z.number().min(0).max(100).optional(),
nota: z.string().optional(),

View File

@@ -39,17 +39,32 @@ export const PromocoesResponseSchema = z.object({
});
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
// Criação aceita VÁRIOS produtos e/ou grupos — o backend cria uma promoção
// por alvo (mesma descrição/%/vigência), mantendo o motor de alçada intacto.
export const CreatePromocaoBodySchema = z.object({
descricao: z.string().min(1).max(255),
codProduto: z.string().optional(),
grpProd: z.string().optional(),
codProdutos: z.array(z.string().min(1)).max(100).optional(),
grpProds: z.array(z.string().min(1)).max(100).optional(),
descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
export type CreatePromocaoBody = z.infer<typeof CreatePromocaoBodySchema>;
export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().extend({
// Edição continua por linha (um alvo por promoção)
export const UpdatePromocaoBodySchema = z.object({
descricao: z.string().min(1).max(255).optional(),
codProduto: z.string().nullable().optional(),
grpProd: z.string().nullable().optional(),
descPct: z.number().min(0).max(100).optional(),
dataInicio: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
dataFim: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
ativa: z.boolean().optional(),
});
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
@@ -81,10 +96,12 @@ export const RegrasDescontoResponseSchema = z.object({
});
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
// Criação aceita VÁRIOS grupos e/ou subgrupos — uma regra por alvo (grupos
// selecionados viram regras por grupo; subgrupos viram regras por subgrupo).
export const CreateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200),
codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().optional(),
codGrupos: z.array(z.number().int()).max(100).optional(),
codSubgrupos: z.array(z.number().int()).max(100).optional(),
codVendedor: z.number().int().nullable().optional(),
descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
@@ -92,11 +109,37 @@ export const CreateRegraDescontoBodySchema = z.object({
});
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
// Edição continua por linha (um alvo por regra)
export const UpdateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200).optional(),
codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().optional(),
codVendedor: z.number().int().nullable().optional(),
descPct: z.number().min(0).max(100).optional(),
dataInicio: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
dataFim: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
ativa: z.boolean().optional(),
});
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
// Alçada do usuário logado — usada pelo carrinho para mostrar o teto por item
export const AlcadaRepItemSchema = z.object({
codGrupo: z.number().int(),
limitePerc: z.number(),
});
export type AlcadaRepItem = z.infer<typeof AlcadaRepItemSchema>;
export const AlcadaRepResponseSchema = z.object({
limites: z.array(AlcadaRepItemSchema),
});
export type AlcadaRepResponse = z.infer<typeof AlcadaRepResponseSchema>;
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
export const PromocaoVigenteSchema = z.object({
id: z.number().int(),

View File

@@ -27,6 +27,8 @@ export const PautaSchema = z.object({
idPauta: z.number().int(),
codigo: z.number().int(),
descricao: z.string(),
// Presente só para gestor: quantos representantes usam esta pauta
qtdReps: z.number().int().optional(),
});
export type Pauta = z.infer<typeof PautaSchema>;

View File

@@ -96,6 +96,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
sar.push_subscription,
sar.promocoes,
sar.regras_desconto,
sar.config_empresa,
sar.clientes_novos,
sar.contatos_novos
TO ${appRole};

View File

@@ -289,7 +289,9 @@ SELECT
COALESCE(p.preco_promocional, 0) AS preco_promocional,
COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote,
p.lista_pauta,
CASE WHEN p.dt_atual > sub.da THEN p.dt_atual ELSE sub.da END AS dt_atual
CASE WHEN p.dt_atual > sub.da THEN p.dt_atual ELSE sub.da END AS dt_atual,
-- vol_m3 no FIM: CREATE OR REPLACE VIEW so aceita coluna nova apos as existentes
COALESCE(p.vol_m3, 0) AS vol_m3
FROM gestao.produto p
LEFT JOIN gestao.grupo grp ON grp.codigo = p.cod_grupo AND grp.id_empresa = p.id_empresa
LEFT JOIN gestao.grupo sub ON sub.codigo = p.cod_subgrupo AND sub.id_empresa = p.id_empresa
@@ -736,6 +738,17 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
created_by VARCHAR(100) NOT NULL
);
-- -----------------------------------------------------------------------------
-- Configuracoes por empresa (logo da impressao do pedido, etc.)
-- logo_base64: data URL (data:image/png;base64,...)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.config_empresa (
id_empresa INTEGER PRIMARY KEY,
logo_base64 TEXT,
meta_positivacao_dia INTEGER,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- -----------------------------------------------------------------------------
-- Oportunidades (Funil de Vendas)
-- -----------------------------------------------------------------------------
@@ -820,6 +833,6 @@ CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagen
-- GRANT INSERT, UPDATE, DELETE ON
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription,
-- sar.promocoes, sar.regras_desconto, sar.clientes_novos, sar.contatos_novos
-- sar.promocoes, sar.regras_desconto, sar.config_empresa, sar.clientes_novos, sar.contatos_novos
-- TO <role>;
-- =============================================================================