Compare commits

..

20 Commits

Author SHA1 Message Date
9107b0a825 fix(api): restringe produtos e precos a pauta vinculada ao representante
Rep so enxerga e vende itens das pautas do cadastro dele (cod_pauta1..6):

- catalog: lista de produtos exige pauta e recusa pauta de terceiro
- orders: create/update validam pauta do pedido e todo item na pauta
  com preco > 0, fechando preco forjado e produto fora da pauta

Gestor/admin seguem navegando livremente. O teto de desconto continua
em assertAlcadaItens; aqui so ancora o preco de tabela.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:29:07 +00:00
9b855f63b2 feat(api,web): regras de desconto por pauta e cards de supervisor na equipe
Regras de Desconto (Politicas Comerciais):
- Nova dimensao de escopo por pauta de precos: cria 1 regra por pauta
  selecionada (idPautas[]), aplicada a todos os produtos da pauta.
- Coluna id_pauta em sar.regras_desconto (migration idempotente + schema
  canonico); motor de alcada (orders) casa regra<->pedido por idPauta.
- Remove a aba/endpoints "Desconto por Representante" (gestao de
  alcada_desconto); o teto por rep continua no motor (default 5%) e no
  carrinho (/politicas/alcada), agora liberado via Regra de Desconto.

Aba Equipe do gerente:
- GET /equipe/supervisores: cards de disputa por supervisor (faturamento,
  meta, positivacao, variacao mensal, reps ativos).
- SupervisorDetalheModal: detalhe do time (ranking + positivacao) reusando
  /dashboard/supervisor escopado por codSupervisor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:12:00 +00:00
1ce8d96fea refactor(api,web): remove fila de aprovacao e consolida painel do supervisor
Alcada agora e bloqueio duro no pedido (sem fluxo de aprovar/recusar):
remove ApprovalQueuePage, CarteirePage e rota /aprovacoes, absorvendo
carteira e acompanhamento de pedidos dentro do SupervisorPainel.
Colunas de desempenho por representante extraidas para componente
compartilhado (rep-performance-columns) entre gerente e supervisor.
Ajusta dashboard e orders (controller/service/contract) para o novo fluxo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:34:39 +00:00
ebf68523dd feat(api,web): card condicoes especiais no painel do representante
Card expansivel no painel do rep com promocoes e regras de desconto
vigentes hoje para o representante logado. Endpoints vigentes agora
resolvem nomes de produto/grupo/subgrupo; hooks com refetch de 60s
para a condicao sumir logo apos encerrar ou inativar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:55 +00:00
78612d59bd feat(api,web): mapa do brasil com faturamento por estado no painel gerencial
- KPIs financeiros agrupados (faturamento, meta, atingimento, falta) em metade da primeira dobra
- MapaBrasilCard: choropleth por UF (@svg-maps/brazil), tooltip, ranking lateral e modal Detalhar com representantes por estado
- API: faturamentoPorUf no /dashboard/manager (pedido -> cliente -> municipio -> UF)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:22:21 +00:00
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
54 changed files with 3838 additions and 2298 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

@@ -0,0 +1,6 @@
-- AlterTable: sar.regras_desconto -- adiciona escopo por pauta de precos.
-- Regra pode ser restrita a uma pauta especifica; id_pauta NULL = qualquer pauta.
-- provision-client.ts roda scripts/sar-erp-schema.sql (idempotente) antes de
-- `prisma migrate deploy`, entao este ALTER precisa ser idempotente tambem.
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
ALTER TABLE sar.regras_desconto ADD COLUMN IF NOT EXISTS id_pauta INTEGER;

View File

@@ -174,7 +174,8 @@ model Promocao {
// ─── RegraDesconto (Políticas Comerciais) ──────────────────────────────────── // ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
// //
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou // Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos". // subgrupo de produto, pauta de preços e/ou representante, com vigência.
// Campos nulos = "todos" (id_pauta nulo = qualquer pauta).
model RegraDesconto { model RegraDesconto {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@@ -183,6 +184,7 @@ model RegraDesconto {
codGrupo Int? @map("cod_grupo") codGrupo Int? @map("cod_grupo")
codSubgrupo Int? @map("cod_subgrupo") codSubgrupo Int? @map("cod_subgrupo")
codVendedor Int? @map("cod_vendedor") codVendedor Int? @map("cod_vendedor")
idPauta Int? @map("id_pauta")
descPct Decimal @db.Decimal(5, 2) @map("desc_pct") descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
dataInicio DateTime @db.Date @map("data_inicio") dataInicio DateTime @db.Date @map("data_inicio")
dataFim DateTime @db.Date @map("data_fim") dataFim DateTime @db.Date @map("data_fim")
@@ -195,6 +197,20 @@ model RegraDesconto {
@@map("regras_desconto") @@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 (Funil de Vendas) ──────────────────────────────────────────
// //
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente) // 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 { createZodDto } from 'nestjs-zod';
import { import {
ProdutoListQuerySchema, ProdutoListQuerySchema,
UpdateLogoBodySchema,
type EmpresaInfo, type EmpresaInfo,
type UpdateLogoBody,
type FormaPagamento, type FormaPagamento,
type Municipio, type Municipio,
type Pauta, type Pauta,
@@ -13,6 +25,7 @@ import {
import { CatalogService } from './catalog.service'; import { CatalogService } from './catalog.service';
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {} 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). // 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(); 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() @Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> { list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery; 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 { ClsService } from 'nestjs-cls';
import type { import type {
EmpresaInfo, EmpresaInfo,
UpdateLogoBody,
FormaPagamento, FormaPagamento,
Municipio, Municipio,
Pauta, Pauta,
@@ -101,6 +102,8 @@ export class CatalogService {
const r = rows[0]; const r = rows[0];
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`); 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 clean = (v: string | null) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
return t === '' ? null : t; return t === '' ? null : t;
@@ -120,9 +123,28 @@ export class CatalogService {
cep: clean(r.cep), cep: clean(r.cep),
telefone: clean(r.telefone), telefone: clean(r.telefone),
email: clean(r.email), 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[]> { async municipios(q?: string): Promise<Municipio[]> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -158,29 +180,49 @@ export class CatalogService {
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0; const codVendedor = userId ? parseInt(userId, 10) : 0;
const role = this.cls.get('role') ?? 'rep';
interface PautaRow { interface PautaRow {
id_pauta: number; id_pauta: number;
codigo: number; codigo: number;
descricao: string; 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
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao // pautas ativas, com a contagem de reps que usam cada uma.
FROM vw_pautas pa const rows =
JOIN vw_representantes r ON pa.codigo IN ( role === 'rep'
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3, ? await prisma.$queryRawUnsafe<PautaRow[]>(`
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6 SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
) FROM vw_pautas pa
WHERE pa.id_empresa = ${idEmpresa} JOIN vw_representantes r ON pa.codigo IN (
AND pa.ativo = 1 r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
AND r.codigo = ${codVendedor} r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
ORDER BY pa.codigo )
`); WHERE pa.id_empresa = ${idEmpresa}
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) => ({ return rows.map((r) => ({
idPauta: Number(r.id_pauta), idPauta: Number(r.id_pauta),
codigo: Number(r.codigo), codigo: Number(r.codigo),
descricao: r.descricao, descricao: r.descricao,
...(r.qtd_reps != null && { qtdReps: Number(r.qtd_reps) }),
})); }));
} }
@@ -188,10 +230,22 @@ export class CatalogService {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const role = this.cls.get('role') ?? 'rep';
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { q, codGrupo, idPauta, page, limit } = query; const { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// PGD-AUTHZ: o rep só enxerga produtos/preços das pautas vinculadas ao
// cadastro dele (cod_pauta1..6). Sem pauta selecionada → lista vazia (precisa
// escolher a pauta primeiro); pauta de terceiro → recusa. Gestor/admin
// navegam livremente (preço base ou pauta que escolherem).
if (role === 'rep') {
if (idPauta == null) return { data: [], total: 0, page, limit };
await this.assertPautaDoRep(idPauta, codVendedor, idEmpresa);
}
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : ''; const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')` ? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
@@ -268,6 +322,33 @@ export class CatalogService {
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit }; return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit };
} }
// Confere que a pauta pertence às pautas do rep (cod_pauta1..6 do cadastro).
// codVendedor/idPauta são Int (JWT/query coercida) — interpolação segura.
private async assertPautaDoRep(
idPauta: number,
codVendedor: number,
idMatriz: number,
): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number }[]>(`
SELECT DISTINCT pa.id_pauta
FROM vw_pautas pa
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 = ${idMatriz}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
AND pa.id_pauta = ${Number(idPauta)}
LIMIT 1
`);
if (!rows[0]) {
throw new ForbiddenException('Pauta de preços não vinculada ao seu cadastro');
}
}
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary { private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return { return {
idErp: Number(p.id_erp), idErp: Number(p.id_erp),
@@ -337,12 +418,16 @@ export class CatalogService {
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
AND pa.id_empresa = ${idEmpresa} AND pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1 AND pa.ativo = 1
JOIN vw_representantes r ON pa.codigo IN ( ${
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3, // Rep vê só as pautas dele; gestor vê os preços de TODAS as pautas
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6 (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} WHERE pp.id_produto = ${idErp}
AND r.codigo = ${codVendedor}
AND pp.preco1 > 0 AND pp.preco1 > 0
ORDER BY pa.codigo ORDER BY pa.codigo
`), `),

View File

@@ -1,9 +1,28 @@
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common'; import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
ParseIntPipe,
Put,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface'; import { createZodDto } from 'nestjs-zod';
import {
SaveMetaPositivacaoBodySchema,
type RepDashboard,
type RepInativosDetail,
type SupervisorDashboard,
type ManagerDashboard,
type SaveMetaPositivacaoBody,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {}
@Controller({ path: 'dashboard' }) @Controller({ path: 'dashboard' })
export class DashboardController { export class DashboardController {
constructor( constructor(
@@ -26,9 +45,29 @@ export class DashboardController {
} }
@Get('supervisor') @Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> { supervisorDashboard(
@Query('codSupervisor') codSupervisor?: string,
): Promise<SupervisorDashboard> {
this.assertGestor(); this.assertGestor();
return this.dashboard.supervisorDashboard(); return this.dashboard.supervisorDashboard(
codSupervisor ? parseInt(codSupervisor, 10) : undefined,
);
}
@Get('supervisor/inativos')
supervisorInativos(
@Query('codVendedor', ParseIntPipe) codVendedor: number,
): Promise<RepInativosDetail> {
this.assertGestor();
return this.dashboard.supervisorInativos(codVendedor);
}
@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') @Get('manager')

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
RepDashboard, RepDashboard,
RepInativosDetail,
SupervisorDashboard, SupervisorDashboard,
ManagerDashboard, ManagerDashboard,
RankingRep, RankingRep,
@@ -11,8 +12,6 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util'; import { getTeamCodes } from '../workspace/team-scope.util';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado // Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
const SITUA_PENDENTE = 1;
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo. // vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
const TIPO_META_GLOBAL = 'GL'; const TIPO_META_GLOBAL = 'GL';
@@ -418,6 +417,18 @@ export class DashboardService {
dia: string; dia: string;
clientes: string; clientes: string;
} }
interface NovosClientesRow {
cod_vendedor: number;
novos: string;
}
interface UfRepRow {
uf: string;
cod_vendedor: number;
nome_vendedor: string | null;
pedidos: string;
clientes: string;
faturamento: string;
}
const [ const [
statsRows, statsRows,
@@ -428,6 +439,8 @@ export class DashboardService {
metaGrupoRows, metaGrupoRows,
realizadoGrupoRows, realizadoGrupoRows,
positivacaoDiariaRows, positivacaoDiariaRows,
novosClientesRows,
ufRepRows,
] = await Promise.all([ ] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(` prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
@@ -535,6 +548,46 @@ export class DashboardService {
GROUP BY dt_pedido::date GROUP BY dt_pedido::date
ORDER BY dia 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
`),
// Faturamento por UF (município do cliente) e representante.
// vw_clientes pode repetir o cliente por empresa → LATERAL pega 1 município.
prisma.$queryRawUnsafe<UfRepRow[]>(`
SELECT COALESCE(NULLIF(TRIM(loc.uf), ''), 'ND') AS uf,
p.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome_vendedor,
COUNT(*)::text AS pedidos,
COUNT(DISTINCT p.id_cliente)::text AS clientes,
COALESCE(SUM(p.total), 0)::text AS faturamento
FROM vw_pedidos_erp p
LEFT JOIN LATERAL (
SELECT mu.uf::text AS uf
FROM vw_clientes c
JOIN sar.vw_municipios mu ON mu.id_municipio = c.id_municipio
WHERE c.id_cliente = p.id_cliente
LIMIT 1
) loc ON true
WHERE p.id_empresa = ${idEmpresa}
AND p.situa NOT IN (1, 5)
AND p.dt_pedido >= '${monthStartStr}'
AND p.dt_pedido <= '${monthEndStr}'
GROUP BY 1, p.cod_vendedor
ORDER BY 1, SUM(p.total) DESC
`),
]); ]);
const pedidosMes = Number(statsRows[0]?.count ?? 0); const pedidosMes = Number(statsRows[0]?.count ?? 0);
@@ -585,6 +638,14 @@ export class DashboardService {
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size; const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); 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 positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
const total = Number(r.total_clientes); const total = Number(r.total_clientes);
const positivados = Number(r.clientes_positivados); const positivados = Number(r.clientes_positivados);
@@ -594,9 +655,42 @@ export class DashboardService {
totalClientes: total, totalClientes: total,
clientesPositivados: positivados, clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0, pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
}; };
}); });
// Agrega as linhas UF×rep em UF → { totais, reps[] } ordenado por faturamento.
const porUf = new Map<
string,
{ faturamento: number; pedidos: number; clientes: number; reps: UfRepRow[] }
>();
for (const r of ufRepRows) {
const acc = porUf.get(r.uf) ?? { faturamento: 0, pedidos: 0, clientes: 0, reps: [] };
acc.faturamento += Number(r.faturamento);
acc.pedidos += Number(r.pedidos);
acc.clientes += Number(r.clientes);
acc.reps.push(r);
porUf.set(r.uf, acc);
}
const faturamentoPorUf = [...porUf.entries()]
.map(([uf, t]) => ({
uf,
faturamento: t.faturamento,
pedidos: t.pedidos,
clientes: t.clientes,
pct: faturamentoMes > 0 ? Math.round((t.faturamento / faturamentoMes) * 1000) / 10 : 0,
reps: t.reps
.map((r) => ({
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
faturamento: Number(r.faturamento),
pedidos: Number(r.pedidos),
clientes: Number(r.clientes),
}))
.sort((a, b) => b.faturamento - a.faturamento),
}))
.sort((a, b) => b.faturamento - a.faturamento);
return { return {
faturamentoMes, faturamentoMes,
pedidosMes, pedidosMes,
@@ -611,35 +705,51 @@ export class DashboardService {
dia: r.dia, dia: r.dia,
clientes: Number(r.clientes), clientes: Number(r.clientes),
})), })),
faturamentoPorUf,
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
async supervisorDashboard(): Promise<SupervisorDashboard> { // 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(codSupervisor?: number): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const now = new Date(); const now = new Date();
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes); // Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
// gerente/admin acessando este painel veem a empresa toda. // Gerente/admin: sem codSupervisor veem a empresa toda; com codSupervisor
// (Detalhar da aba Equipe) veem o time daquele supervisor. codSupervisor é
// ignorado para role 'supervisor' — não pode espiar outra equipe.
const role = this.cls.get('role'); const role = this.cls.get('role');
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const team = let teamCode: number | null = null;
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null; if (role === 'supervisor') {
teamCode = userId ? parseInt(userId, 10) : 0;
} else if (codSupervisor && codSupervisor > 0) {
teamCode = codSupervisor;
}
const team = teamCode ? await getTeamCodes(prisma, teamCode) : null;
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : ''); const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
const approvalQueue = await prisma.pedido.findMany({
where: {
idEmpresa,
situa: SITUA_PENDENTE,
...(team ? { codVendedor: { in: team } } : {}),
},
orderBy: { dtPedido: 'asc' },
take: 50,
});
// Pedidos do dia — lê do ERP (situa != 5=Cancelado) // Pedidos do dia — lê do ERP (situa != 5=Cancelado)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayStr = todayStart.toISOString().slice(0, 10); const todayStr = todayStart.toISOString().slice(0, 10);
@@ -670,7 +780,152 @@ export class DashboardService {
`), `),
]); ]);
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP) // Métricas do mês corrente da equipe — mesmas contas do painel gerencial,
// escopadas pelos códigos da equipe (gerente/admin sem filtro = empresa toda).
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
interface RankingRow {
cod_vendedor: number;
nome: string | null;
pedidos: string;
clientes_atendidos: string;
faturamento: string;
meta_valor: string | null;
}
interface MetaTotalRow {
meta_total: string;
}
interface PositivacaoRow {
cod_vendedor: number;
nome_vendedor: string | null;
total_clientes: string;
clientes_positivados: string;
}
interface NovosClientesRow {
cod_vendedor: number;
novos: string;
}
const [mesRows, rankingRows, metaTotalRows, positivacaoRows, novosClientesRows] =
await Promise.all([
prisma.$queryRawUnsafe<DayRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
${teamSqlFilter('cod_vendedor')}
`),
prisma.$queryRawUnsafe<RankingRow[]>(`
SELECT p.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
COUNT(*)::text AS pedidos,
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
COALESCE(SUM(p.total), 0)::text AS faturamento,
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
WHERE m.id_empresa = ${idEmpresaMatriz}
AND m.cod_vendedor = p.cod_vendedor
AND m.ano = ${year}
AND m.mes = ${month}
AND TRIM(m.tipo) = '${TIPO_META_GRUPO}') AS meta_valor
FROM vw_pedidos_erp p
WHERE p.id_empresa = ${idEmpresa}
AND p.situa NOT IN (1, 5)
AND p.dt_pedido >= '${monthStartStr}'
AND p.dt_pedido <= '${monthEndStr}'
${teamSqlFilter('p.cod_vendedor')}
GROUP BY p.cod_vendedor
ORDER BY SUM(p.total) DESC
LIMIT 10
`),
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
FROM sar.vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) = '${TIPO_META_GRUPO}'
${teamSqlFilter('cod_vendedor')}
`),
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
SELECT
c.cod_vendedor,
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor,
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados
FROM vw_clientes c
LEFT JOIN (
SELECT DISTINCT id_cliente, cod_vendedor
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
WHERE c.cod_vendedor > 0
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.cod_vendedor
HAVING COUNT(DISTINCT c.id_cliente) > 0
ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC
`),
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}'
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.cod_vendedor
`),
]);
const pedidosMes = Number(mesRows[0]?.count ?? 0);
const faturamentoMes = Number(mesRows[0]?.total ?? 0);
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
const rankingReps: RankingRep[] = rankingRows.map((r) => {
const fat = Number(r.faturamento);
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
return {
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome ?? null,
pedidos: Number(r.pedidos),
clientesAtendidos: Number(r.clientes_atendidos),
faturamento: fat,
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
};
});
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);
return {
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
totalClientes: total,
clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
};
});
// Reps com mais clientes inativos (>30 dias sem compra no ERP)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(` const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
SELECT inativos.cod_vendedor, SELECT inativos.cod_vendedor,
@@ -693,50 +948,10 @@ export class DashboardService {
) inativos ) inativos
GROUP BY inativos.cod_vendedor GROUP BY inativos.cod_vendedor
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
LIMIT 3 LIMIT 10
`); `);
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
const [repNameRows, cliNameRows] = await Promise.all([
repCods.length
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
)
: Promise.resolve([]),
cliIds.length
? prisma.$queryRawUnsafe<
{ id_cliente: number; nome: string | null; razao: string | null }[]
>(
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
)
: Promise.resolve([]),
]);
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const cliNameMap = new Map(
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
);
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
id: o.id,
numPedSar: o.numPedSar,
idCliente: o.idCliente,
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
codVendedor: o.codVendedor,
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: String(o.total),
descontoPerc: String(o.descontoPerc),
obs: o.obs,
createdAt: o.createdAt.toISOString(),
fonte: 'sar' as const,
});
return { return {
approvalQueue: approvalQueue.map(mapPedido),
pedidosDia: { pedidosDia: {
count: Number(todayRows[0]?.count ?? 0), count: Number(todayRows[0]?.count ?? 0),
total: Number(todayRows[0]?.total ?? 0), total: Number(todayRows[0]?.total ?? 0),
@@ -748,7 +963,93 @@ export class DashboardService {
nomeVendedor: r.nome_vendedor ?? null, nomeVendedor: r.nome_vendedor ?? null,
inativosCount: parseInt(r.inativos_count, 10), inativosCount: parseInt(r.inativos_count, 10),
})), })),
equipeMes: {
faturamento: faturamentoMes,
pedidos: pedidosMes,
ticketMedio: pedidosMes > 0 ? faturamentoMes / pedidosMes : 0,
metaTotal,
pctMeta: metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0,
},
rankingReps,
positivacaoReps,
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
// Clientes inativos (>30 dias sem compra) de um representante — detalhe do
// card "Inativos por Rep". Supervisor só consulta reps da própria equipe;
// gerente/admin consultam qualquer rep.
async supervisorInativos(codVendedor: number): Promise<RepInativosDetail> {
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');
if (role === 'supervisor') {
const team = await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0);
if (!team.includes(codVendedor)) {
throw new ForbiddenException('Representante fora da sua equipe');
}
}
// Mesmo corte do resumo inativosPorRep: sem compra há mais de 30 dias.
const cutoffStr = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const rows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
WITH ultimo_pedido AS (
SELECT id_cliente, MAX(dt_pedido) AS dt_max
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa != 5
GROUP BY id_cliente
)
SELECT
c.id_cliente,
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
(up.dt_max IS NOT NULL) AS comprou_antes,
ct.whatsapp
FROM vw_clientes c
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
LEFT JOIN LATERAL (
SELECT TRIM(whatsapp) AS whatsapp
FROM sar.vw_contatos
WHERE id_corrent = c.id_cliente
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
AND ativo = 1
LIMIT 1
) ct ON true
WHERE c.cod_vendedor = ${codVendedor}
AND c.ativo = 1
AND (up.dt_max IS NULL OR up.dt_max < '${cutoffStr}')
ORDER BY up.dt_max ASC NULLS FIRST
`);
const nomeRows = await prisma.$queryRawUnsafe<{ nome: string | null }[]>(
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
);
// Deduplica por idCliente — vw_clientes pode repetir o cliente por empresa.
const seen = new Set<number>();
const clientes = rows
.filter((c) => {
const id = Number(c.id_cliente);
if (seen.has(id)) return false;
seen.add(id);
return true;
})
.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
razao: c.razao ?? null,
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
ultimoPedido: c.dt_ultimo_pedido ?? null,
comprouAntes: Boolean(c.comprou_antes),
whatsapp: c.whatsapp ?? null,
}));
return { codVendedor, nomeVendedor: nomeRows[0]?.nome ?? null, clientes };
}
} }

View File

@@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import type { EquipeResponse } from '@sar/api-interface'; import type { EquipeResponse, SupervisoresResponse } from '@sar/api-interface';
import { EquipeService } from './equipe.service'; import { EquipeService } from './equipe.service';
@Controller({ path: 'equipe' }) @Controller({ path: 'equipe' })
@@ -10,4 +10,9 @@ export class EquipeController {
listEquipe(): Promise<EquipeResponse> { listEquipe(): Promise<EquipeResponse> {
return this.equipe.listEquipe(); return this.equipe.listEquipe();
} }
@Get('supervisores')
listSupervisores(): Promise<SupervisoresResponse> {
return this.equipe.listSupervisores();
}
} }

View File

@@ -1,6 +1,6 @@
import { ForbiddenException, Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { EquipeResponse } from '@sar/api-interface'; import type { EquipeResponse, SupervisorCard, SupervisoresResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
function matrizEmpresa(idEmpresa: number): number { function matrizEmpresa(idEmpresa: number): number {
@@ -110,4 +110,173 @@ export class EquipeService {
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
// Cards de supervisor da aba Equipe: agrega as métricas do mês por equipe.
// Time de um supervisor = reps cujo cod_supervisor aponta para ele + ele
// próprio (mesma regra de getTeamCodes, que o Detalhe reusa via
// /dashboard/supervisor?codSupervisor=). Agregação feita em JS a partir de
// métricas por rep — as mesmas contas do painel, sem escopo por equipe.
async listSupervisores(): Promise<SupervisoresResponse> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem consultar os supervisores');
}
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 idEmpresaMatriz = matrizEmpresa(idEmpresa);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
const prevStartStr = new Date(year, month - 2, 1).toISOString().slice(0, 10);
const prevEndStr = new Date(year, month - 1, 0).toISOString().slice(0, 10);
const [repRows, pedidosRows, prevRows, metaRows, positivacaoRows] = await Promise.all([
prisma.$queryRawUnsafe<
{ cod_vendedor: number; cod_supervisor: number | null; nome: string | null }[]
>(`SELECT codigo AS cod_vendedor, cod_supervisor, nome FROM vw_representantes`),
prisma.$queryRawUnsafe<PedidosRepRow[]>(`
SELECT cod_vendedor,
COUNT(*)::text AS pedidos,
COALESCE(SUM(total), 0)::text AS faturamento
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<{ cod_vendedor: number; faturamento: string }[]>(`
SELECT cod_vendedor,
COALESCE(SUM(total), 0)::text AS faturamento
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${prevStartStr}'
AND dt_pedido <= '${prevEndStr}'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<MetaRepRow[]>(`
SELECT cod_vendedor,
SUM(valor)::text AS meta_valor
FROM sar.vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) = 'GR'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<
{ cod_vendedor: number; total_clientes: string; positivados: string }[]
>(`
SELECT c.cod_vendedor,
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS positivados
FROM vw_clientes c
LEFT JOIN (
SELECT DISTINCT id_cliente, cod_vendedor
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
WHERE c.cod_vendedor > 0
GROUP BY c.cod_vendedor
`),
]);
const pedidosMap = new Map(
pedidosRows.map((r) => [
Number(r.cod_vendedor),
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
]),
);
const prevMap = new Map(prevRows.map((r) => [Number(r.cod_vendedor), Number(r.faturamento)]));
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
const posMap = new Map(
positivacaoRows.map((r) => [
Number(r.cod_vendedor),
{ total: Number(r.total_clientes), positivados: Number(r.positivados) },
]),
);
// Monta a equipe de cada supervisor: código do supervisor -> códigos dos reps.
// O supervisor entra no próprio time (getTeamCodes inclui o supervisor).
const nomeByCod = new Map<number, string | null>();
const membrosBySup = new Map<number, Set<number>>();
for (const r of repRows) {
const cod = Number(r.cod_vendedor);
if (!nomeByCod.has(cod)) nomeByCod.set(cod, r.nome ?? null);
const sup = r.cod_supervisor != null ? Number(r.cod_supervisor) : 0;
if (sup > 0) {
let membros = membrosBySup.get(sup);
if (!membros) {
membros = new Set();
membrosBySup.set(sup, membros);
}
membros.add(cod);
}
}
const supervisores: SupervisorCard[] = [...membrosBySup.entries()].map(([sup, membros]) => {
const team = new Set(membros);
team.add(sup); // o próprio supervisor faz parte do time
let faturamento = 0;
let faturamentoAnterior = 0;
let pedidos = 0;
let meta = 0;
let totalClientes = 0;
let positivados = 0;
let repsAtivos = 0;
for (const cod of team) {
const p = pedidosMap.get(cod);
if (p) {
faturamento += p.faturamento;
pedidos += p.pedidos;
if (p.faturamento > 0) repsAtivos += 1;
}
faturamentoAnterior += prevMap.get(cod) ?? 0;
meta += metaMap.get(cod) ?? 0;
const pos = posMap.get(cod);
if (pos) {
totalClientes += pos.total;
positivados += pos.positivados;
}
}
return {
codSupervisor: sup,
nomeSupervisor: nomeByCod.get(sup) ?? null,
faturamentoMes: faturamento,
faturamentoMesAnterior: faturamentoAnterior,
variacaoPct:
faturamentoAnterior > 0
? Math.round(((faturamento - faturamentoAnterior) / faturamentoAnterior) * 100)
: null,
pedidosMes: pedidos,
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
metaTotal: meta,
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
totalClientes,
clientesPositivados: positivados,
pctPositivacao: totalClientes > 0 ? Math.round((positivados / totalClientes) * 100) : 0,
repsTotal: team.size,
repsAtivos,
};
});
// Ranking/disputa: maior faturamento primeiro.
supervisores.sort((a, b) => b.faturamentoMes - a.faturamentoMes);
return {
supervisores,
syncedAt: now.toISOString(),
};
}
} }

View File

@@ -1,7 +1,6 @@
import { import {
Body, Body,
Controller, Controller,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
Param, Param,
@@ -11,38 +10,33 @@ import {
Post, Post,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
AprovarPedidoSchema, CancelPedidoSchema,
CreatePedidoSchema, CreatePedidoSchema,
UpdatePedidoSchema,
PedidoErpConsultaQuerySchema, PedidoErpConsultaQuerySchema,
PedidoListQuerySchema, PedidoListQuerySchema,
RecusarPedidoSchema, type CancelPedido,
type AprovarPedido,
type CreatePedido, type CreatePedido,
type UpdatePedido,
type PedidoDetail, type PedidoDetail,
type PedidoErpConsultaQuery, type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse, type PedidoErpConsultaResponse,
type PedidoListQuery, type PedidoListQuery,
type PedidoListResponse, type PedidoListResponse,
type RecusarPedido,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {} class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {} class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {} class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {} class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {} class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' }) @Controller({ path: 'orders' })
export class OrdersController { export class OrdersController {
constructor( constructor(private readonly orders: OrdersService) {}
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get() @Get()
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> { list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
@@ -62,31 +56,23 @@ export class OrdersController {
return this.orders.transmit(id); return this.orders.transmit(id);
} }
@Patch(':id/approve')
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: AprovarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
return this.orders.approve(id, parsed);
}
@Patch(':id/reject')
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: RecusarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
return this.orders.reject(id, parsed);
}
@Patch(':id/cancel') @Patch(':id/cancel')
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> { cancel(
return this.orders.cancel(id); @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') @Get('erp/:idPedido')

View File

@@ -2,8 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { import type {
AprovarPedido, CancelPedido,
CreatePedido, CreatePedido,
UpdatePedido,
PedidoDetail, PedidoDetail,
PedidoErpConsultaItem, PedidoErpConsultaItem,
PedidoErpConsultaQuery, PedidoErpConsultaQuery,
@@ -11,16 +12,14 @@ import type {
PedidoListQuery, PedidoListQuery,
PedidoListResponse, PedidoListResponse,
PedidoSummary, PedidoSummary,
RecusarPedido,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util'; import { getTeamCodes } from '../workspace/team-scope.util';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado // Situa SAR: 0=Orçamento, 2=Confirmado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado // Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
const SITUA_ORCAMENTO = 0; const SITUA_ORCAMENTO = 0;
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2; const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3; const SITUA_CANCELADO = 3;
@@ -288,20 +287,6 @@ export class OrdersService {
return { data, total, page, limit }; return { data, total, page, limit };
} }
// Supervisor só age sobre pedidos da própria equipe; gerente/admin passam
// direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes.
private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise<void> {
const role = this.cls.get('role');
if (role !== 'supervisor') return;
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const userId = this.cls.get('userId') ?? '0';
const team = await getTeamCodes(prisma, parseInt(userId, 10));
if (!team.includes(codVendedorPedido)) {
throw new NotFoundException(`Pedido ${idPedido} não encontrado`);
}
}
async findOne(id: string): Promise<PedidoDetail> { async findOne(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -330,8 +315,119 @@ export class OrdersService {
return this.mapDetail(o); return this.mapDetail(o);
} }
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a // PGD-AUTHZ: o rep só vende produtos/preços das pautas vinculadas ao cadastro
// notificação ao supervisor acontecem no transmit(), não aqui. // dele (cod_pauta1..6). Valida que (a) a pauta do pedido é do rep e (b) todo
// item existe naquela pauta com preço > 0 — fecha preço forjado (ex.: 0,01) e
// produto fantasma/fora da pauta. O TETO de desconto (alçada + promoção/regra
// ativa da política comercial) continua em assertAlcadaItens; aqui só ancora o
// preço de tabela na pauta do rep para que aquele cálculo nunca caia no
// fallback de "sem preço de referência".
private async assertPautaDoRep(
codVendedor: number,
idPauta: number | null | undefined,
itens: { ordem: number; idProduto: number; codProduto?: string | null }[],
): 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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
if (idPauta == null) {
throw new BadRequestException('Selecione a pauta de preços para lançar o pedido.');
}
// codVendedor (JWT) e idPauta/idProduto (payload validado como Int) — seguros.
const pautaRows = await prisma.$queryRawUnsafe<{ id_pauta: number }[]>(`
SELECT DISTINCT pa.id_pauta
FROM vw_pautas pa
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 = ${idMatriz}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
AND pa.id_pauta = ${Number(idPauta)}
LIMIT 1
`);
if (!pautaRows[0]) {
throw new BadRequestException('Pauta de preços não vinculada ao seu cadastro.');
}
const prodIds = [...new Set(itens.map((i) => i.idProduto))];
if (prodIds.length === 0) return;
const okRows = await prisma.$queryRawUnsafe<{ id_produto: number }[]>(`
SELECT pp.id_produto
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto
AND p.id_empresa = ${idMatriz}
AND p.ativo = 1
WHERE pp.id_pauta = ${Number(idPauta)}
AND pp.preco1 > 0
AND pp.id_produto IN (${prodIds.join(',')})
`);
const validos = new Set(okRows.map((r) => Number(r.id_produto)));
const foraDaPauta = itens.filter((it) => !validos.has(it.idProduto));
if (foraDaPauta.length > 0) {
const lista = foraDaPauta
.map((it) => `item ${it.ordem} (${it.codProduto ?? it.idProduto})`)
.join(', ');
throw new BadRequestException(
`Produto fora da pauta selecionada: ${lista}. Só é possível vender itens da sua pauta de preços.`,
);
}
}
// 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). // Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreatePedido): Promise<PedidoDetail> { async create(dto: CreatePedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -365,6 +461,14 @@ export class OrdersService {
if (existing) return this.mapDetail(existing); if (existing) return this.mapDetail(existing);
} }
// Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
if (role === 'rep') {
await this.assertPautaDoRep(codVendedor, dto.idPauta, dto.itens);
}
// 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 itemsData = dto.itens.map((it) => {
const descontoValor = const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100; Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
@@ -496,23 +600,23 @@ export class OrdersService {
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO // 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 // 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 // global — deve caber no maior valor entre a alçada do grupo do produto
// codGrupo; 0 = default), somado à maior folga entre promoção ativa // (alcada_desconto por codGrupo; 0 = default), a promoção ativa
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o // (produto/grupo) e a regra de desconto vigente (grupo/subgrupo/rep) — teto,
// desconto lançado no item E desconto disfarçado de preço unitário reduzido. // não soma. Cobre o desconto lançado no item E desconto disfarçado de preço
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem // unitário reduzido. Preço de tabela: pauta do pedido > promocional > preço
// referência valida só pelo campo de desconto. // base; produto sem referência valida só pelo campo de desconto.
private async assertAlcadaItens( private async assertAlcadaItens(
pedido: { pedido: {
codVendedor: number; codVendedor: number;
descontoPerc: Prisma.Decimal; descontoPerc: Prisma.Decimal | number;
idPauta: number | null; idPauta: number | null;
itens: { itens: {
ordem: number; ordem: number;
idProduto: number; idProduto: number;
codProduto: string | null; codProduto: string | null;
precoUnitario: Prisma.Decimal; precoUnitario: Prisma.Decimal | number;
descontoPerc: Prisma.Decimal; descontoPerc: Prisma.Decimal | number;
}[]; }[];
}, },
limitMap: Map<number, number>, limitMap: Map<number, number>,
@@ -590,20 +694,25 @@ export class OrdersService {
) )
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0); .reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou // Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou
// subgrupo, o produto precisa casar com o que a regra especifica. // pauta, precisa casar com o que a regra especifica. Regra por pauta só
// vale quando o pedido usa aquela pauta (id_pauta nulo = qualquer pauta).
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null; const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
const regraPct = regras const regraPct = regras
.filter( .filter(
(r) => (r) =>
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) && (r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)), (r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)) &&
(r.idPauta == null || (pedido.idPauta != null && r.idPauta === pedido.idPauta)),
) )
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0); .reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
const limiteItem = // Promoção/regra definem TETO próprio, não somam à alçada: com alçada 5%
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) + // e regra de 15%, o máximo do item é 15% (não 20%). Vale o maior entre a
Math.max(promoPct, regraPct); // 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 // Preço de tabela: pauta do pedido > promocional (se menor) > preço base
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0; const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
@@ -629,121 +738,108 @@ export class OrdersService {
if (problemas.length > 0) { if (problemas.length > 0) {
throw new BadRequestException( 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.`,
); );
} }
} }
async approve(id: string, dto: AprovarPedido): 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'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0'; const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10); const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } }); 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) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE) if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException( throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`, 'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
); );
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date(); // PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc); if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
const newTotal = const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100; `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`);
}
}
await prisma.pedido.update({ // Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
if (role === 'rep') {
await this.assertPautaDoRep(pedido.codVendedor, dto.idPauta, dto.itens);
}
// 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 }, where: { id },
data: { data: {
situa: SITUA_APROVADO, idCliente: dto.idCliente,
descontoPerc: newDescontoPerc, idPauta: dto.idPauta ?? null,
total: newTotal, codFormapag: dto.codFormapag ?? null,
aprovadoPor: codVendedor, totalProdutos,
aprovadoEm: now, 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',
},
],
},
}, },
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_APROVADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.nota ?? null,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: { include: {
itens: { orderBy: { ordem: 'asc' } }, itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } }, historico: { orderBy: { changedAt: 'asc' } },
}, },
}); });
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido aprovado',
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final); return this.mapDetail(final);
} }
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> { 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');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
await prisma.pedido.update({
where: { id },
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.motivo,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido recusado',
body: `${final.numPedSar}: ${dto.motivo}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
async cancel(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -757,8 +853,15 @@ export class OrdersService {
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante'); throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
const now = new Date(); 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({ await prisma.historicoPedido.create({
data: { data: {
idPedido: id, idPedido: id,
@@ -766,7 +869,7 @@ export class OrdersService {
situaNova: SITUA_CANCELADO, situaNova: SITUA_CANCELADO,
changedBy: codVendedor, changedBy: codVendedor,
changedAt: now, changedAt: now,
nota: 'Cancelado pelo representante', nota: `Cancelado pelo representante - ${motivoCompleto}`,
}, },
}); });
@@ -818,6 +921,8 @@ export class OrdersService {
codVendedor: number; codVendedor: number;
situa: number; situa: number;
dtPedido: Date; dtPedido: Date;
idPauta: number | null;
codFormapag: number | null;
total: Prisma.Decimal; total: Prisma.Decimal;
descontoPerc: Prisma.Decimal; descontoPerc: Prisma.Decimal;
descontoValor: Prisma.Decimal; descontoValor: Prisma.Decimal;
@@ -856,6 +961,41 @@ export class OrdersService {
}[]; }[];
}): Promise<PedidoDetail> { }): Promise<PedidoDetail> {
const names = await this.lookupNames(o.idCliente, o.codVendedor); 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 { return {
id: o.id, id: o.id,
numPedSar: o.numPedSar, numPedSar: o.numPedSar,
@@ -866,6 +1006,10 @@ export class OrdersService {
nomeVendedor: names.nomeVendedor, nomeVendedor: names.nomeVendedor,
situa: o.situa, situa: o.situa,
dtPedido: o.dtPedido.toISOString(), dtPedido: o.dtPedido.toISOString(),
idPauta: o.idPauta,
codFormapag: o.codFormapag,
pesoTotal: pesoTotal.toFixed(3),
m3Total: m3Total.toFixed(4),
total: decimalToString(o.total), total: decimalToString(o.total),
descontoPerc: decimalToString(o.descontoPerc), descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs, obs: o.obs,
@@ -894,6 +1038,14 @@ export class OrdersService {
precoUnitario: decimalToString(it.precoUnitario), precoUnitario: decimalToString(it.precoUnitario),
descontoPerc: decimalToString(it.descontoPerc), descontoPerc: decimalToString(it.descontoPerc),
total: decimalToString(it.total), 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) => ({ historico: o.historico.map((h) => ({
id: h.id, id: h.id,
@@ -948,6 +1100,8 @@ export class OrdersService {
preco_unitario: string; preco_unitario: string;
desconto_perc: string; desconto_perc: string;
total: string; total: string;
peso_liquido: string | null;
vol_m3: string | null;
} }
const vendedorFilter = const vendedorFilter =
@@ -980,7 +1134,8 @@ export class OrdersService {
SELECT ei.ordem, ei.id_produto, SELECT ei.ordem, ei.id_produto,
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao, TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
ei.qtd::text, ei.preco_unitario::text, 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 FROM vw_peditens_erp ei
LEFT JOIN vw_produtos p LEFT JOIN vw_produtos p
ON p.id_erp = ei.id_produto ON p.id_erp = ei.id_produto
@@ -993,6 +1148,15 @@ export class OrdersService {
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`); if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
const h = headerRows[0]; 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 { return {
id: `erp-${h.id_pedido}`, id: `erp-${h.id_pedido}`,
numPedSar: (h.num_ped_sar ?? '').trim(), numPedSar: (h.num_ped_sar ?? '').trim(),
@@ -1023,6 +1187,8 @@ export class OrdersService {
aprovadoEm: null, aprovadoEm: null,
motivoRecusa: null, motivoRecusa: null,
idempotencyKey: null, idempotencyKey: null,
pesoTotal: pesoTotalErp.toFixed(3),
m3Total: m3TotalErp.toFixed(4),
itens: itemRows.map((it) => ({ itens: itemRows.map((it) => ({
id: `${idPedido}-${it.ordem}`, id: `${idPedido}-${it.ordem}`,
idProduto: Number(it.id_produto), idProduto: Number(it.id_produto),

View File

@@ -11,26 +11,23 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
UpsertDescontoBodySchema,
CreatePromocaoBodySchema, CreatePromocaoBodySchema,
UpdatePromocaoBodySchema, UpdatePromocaoBodySchema,
CreateRegraDescontoBodySchema, CreateRegraDescontoBodySchema,
UpdateRegraDescontoBodySchema, UpdateRegraDescontoBodySchema,
type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type CreateRegraDescontoBody, type CreateRegraDescontoBody,
type UpdateRegraDescontoBody, type UpdateRegraDescontoBody,
type AlcadaDescontosResponse,
type PromocoesResponse, type PromocoesResponse,
type PromocoesVigentesResponse, type PromocoesVigentesResponse,
type AlcadaRepResponse,
type RegrasDescontoResponse, type RegrasDescontoResponse,
type RegrasVigentesResponse, type RegrasVigentesResponse,
type GruposProdutoResponse, type GruposProdutoResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { PoliticasService } from './politicas.service'; import { PoliticasService } from './politicas.service';
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {} class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
@@ -40,17 +37,6 @@ class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema)
export class PoliticasController { export class PoliticasController {
constructor(private readonly politicas: PoliticasService) {} constructor(private readonly politicas: PoliticasService) {}
@Get('descontos')
listDescontos(): Promise<AlcadaDescontosResponse> {
return this.politicas.listDescontos();
}
@Post('descontos')
@HttpCode(200)
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
}
@Get('promocoes') @Get('promocoes')
listPromocoes(): Promise<PromocoesResponse> { listPromocoes(): Promise<PromocoesResponse> {
return this.politicas.listPromocoes(); return this.politicas.listPromocoes();
@@ -61,8 +47,13 @@ export class PoliticasController {
return this.politicas.listPromocoesVigentes(); return this.politicas.listPromocoesVigentes();
} }
@Get('alcada')
minhaAlcada(): Promise<AlcadaRepResponse> {
return this.politicas.minhaAlcada();
}
@Post('promocoes') @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); return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
} }
@@ -91,7 +82,7 @@ export class PoliticasController {
} }
@Post('regras') @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); return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
} }

View File

@@ -1,8 +1,6 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
AlcadaDescontosResponse,
UpsertDescontoBody,
PromocoesResponse, PromocoesResponse,
CreatePromocaoBody, CreatePromocaoBody,
UpdatePromocaoBody, UpdatePromocaoBody,
@@ -11,6 +9,7 @@ import type {
UpdateRegraDescontoBody, UpdateRegraDescontoBody,
RegrasVigentesResponse, RegrasVigentesResponse,
PromocoesVigentesResponse, PromocoesVigentesResponse,
AlcadaRepResponse,
GruposProdutoResponse, GruposProdutoResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -26,61 +25,6 @@ export class PoliticasService {
} }
} }
async listDescontos(): Promise<AlcadaDescontosResponse> {
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 rows = await prisma.alcadaDesconto.findMany({
where: { idEmpresa },
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
});
// Busca nomes dos representantes
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
const repRows =
codigos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
)
: [];
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
return {
descontos: rows.map((r) => ({
codVendedor: r.codVendedor,
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
})),
};
}
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
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');
await prisma.alcadaDesconto.upsert({
where: {
codVendedor_idEmpresa_codGrupo: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
},
},
create: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
limitePerc: body.limitePerc,
},
update: { limitePerc: body.limitePerc },
});
}
async listPromocoes(): Promise<PromocoesResponse> { async listPromocoes(): Promise<PromocoesResponse> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -108,26 +52,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(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system'; const userId = this.cls.get('userId') ?? 'system';
const p = await prisma.promocao.create({ const alvos: { codProduto: string | null; grpProd: string | null }[] = [
data: { ...(body.codProdutos ?? []).map((c) => ({ codProduto: c, grpProd: null })),
idEmpresa, ...(body.grpProds ?? []).map((g) => ({ codProduto: null, grpProd: g })),
descricao: body.descricao, ];
codProduto: body.codProduto ?? null, if (alvos.length === 0) alvos.push({ codProduto: null, grpProd: null });
grpProd: body.grpProd ?? null,
descPct: body.descPct, const ids = await prisma.$transaction(async (tx) => {
dataInicio: new Date(body.dataInicio), const created: number[] = [];
dataFim: new Date(body.dataFim), for (const alvo of alvos) {
createdBy: userId, const p = await tx.promocao.create({
}, data: {
idEmpresa,
descricao: body.descricao,
codProduto: alvo.codProduto,
grpProd: alvo.grpProd,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
created.push(p.id);
}
return created;
}); });
return { id: p.id }; return { ids };
} }
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> { async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
@@ -204,6 +163,23 @@ export class PoliticasService {
}; };
} }
// Descrições das pautas informadas — UI sempre mostra o nome, nunca só o id.
private async loadPautaNames(idPautas: number[]): Promise<Map<number, string | null>> {
if (idPautas.length === 0) return new Map();
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number; descricao: string | null }[]>(
`SELECT id_pauta, TRIM(descricao) AS descricao
FROM vw_pautas
WHERE id_empresa = ${idMatriz}
AND id_pauta IN (${idPautas.join(',')})`,
);
return new Map(rows.map((r) => [Number(r.id_pauta), r.descricao]));
}
async listRegras(): Promise<RegrasDescontoResponse> { async listRegras(): Promise<RegrasDescontoResponse> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -236,6 +212,9 @@ export class PoliticasService {
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
); );
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
const pautaNameMap = await this.loadPautaNames(idPautas);
return { return {
regras: rows.map((r) => ({ regras: rows.map((r) => ({
id: r.id, id: r.id,
@@ -244,6 +223,8 @@ export class PoliticasService {
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
codSubgrupo: r.codSubgrupo, codSubgrupo: r.codSubgrupo,
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
idPauta: r.idPauta,
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
codVendedor: r.codVendedor, codVendedor: r.codVendedor,
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null, nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
descPct: Number(r.descPct), descPct: Number(r.descPct),
@@ -256,27 +237,50 @@ export class PoliticasService {
}; };
} }
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> { // Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo
// vira regra por subgrupo e cada pauta vira regra por pauta. O representante
// escolhido é copiado em todas. Sem alvos, regra vale para todos.
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system'; const userId = this.cls.get('userId') ?? 'system';
const r = await prisma.regraDesconto.create({ const alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] =
data: { [
idEmpresa, ...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null, idPauta: null })),
descricao: body.descricao, ...(body.codSubgrupos ?? []).map((s) => ({
codGrupo: body.codGrupo ?? null, codGrupo: null,
codSubgrupo: body.codSubgrupo ?? null, codSubgrupo: s,
codVendedor: body.codVendedor ?? null, idPauta: null,
descPct: body.descPct, })),
dataInicio: new Date(body.dataInicio), ...(body.idPautas ?? []).map((p) => ({ codGrupo: null, codSubgrupo: null, idPauta: p })),
dataFim: new Date(body.dataFim), ];
createdBy: userId, if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null, idPauta: 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: alvo.codGrupo,
codSubgrupo: alvo.codSubgrupo,
idPauta: alvo.idPauta,
codVendedor: body.codVendedor ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
created.push(r.id);
}
return created;
}); });
return { id: r.id }; return { ids };
} }
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> { async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
@@ -294,6 +298,7 @@ export class PoliticasService {
...(body.descricao !== undefined && { descricao: body.descricao }), ...(body.descricao !== undefined && { descricao: body.descricao }),
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }), ...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }), ...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
...(body.idPauta !== undefined && { idPauta: body.idPauta }),
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }), ...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
...(body.descPct !== undefined && { descPct: body.descPct }), ...(body.descPct !== undefined && { descPct: body.descPct }),
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }), ...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
@@ -315,6 +320,25 @@ export class PoliticasService {
await prisma.regraDesconto.delete({ where: { id } }); 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 // Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
// sinalizar no catálogo que o produto tem condição comercial diferenciada. // sinalizar no catálogo que o produto tem condição comercial diferenciada.
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> { async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
@@ -334,12 +358,36 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }], orderBy: [{ descPct: 'desc' }],
}); });
// Nomes de produto e grupo — UI sempre mostra o nome, nunca só o código
const codProdutos = [
...new Set(
rows.map((p) => p.codProduto?.trim()).filter((c): c is string => !!c && c.length > 0),
),
];
const prodRows =
codProdutos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: string; descricao: string | null }[]>(
`SELECT TRIM(codigo) AS codigo, TRIM(descricao) AS descricao
FROM vw_produtos
WHERE id_empresa = ${idMatriz}
AND TRIM(codigo) IN (${codProdutos.map((c) => `'${c.replace(/'/g, "''")}'`).join(',')})`,
)
: [];
const prodNameMap = new Map(prodRows.map((r) => [r.codigo, r.descricao]));
const grupos = rows.some((p) => p.grpProd != null) ? await this.loadGruposProduto() : [];
const grupoNameMap = new Map(
grupos.filter((g) => g.cod_grupo != null).map((g) => [String(Number(g.cod_grupo)), g.grupo]),
);
return { return {
promocoes: rows.map((p) => ({ promocoes: rows.map((p) => ({
id: p.id, id: p.id,
descricao: p.descricao, descricao: p.descricao,
codProduto: p.codProduto, codProduto: p.codProduto,
nomeProduto: p.codProduto ? (prodNameMap.get(p.codProduto.trim()) ?? null) : null,
grpProd: p.grpProd, grpProd: p.grpProd,
grupo: p.grpProd ? (grupoNameMap.get(p.grpProd.trim()) ?? null) : null,
descPct: Number(p.descPct), descPct: Number(p.descPct),
dataFim: p.dataFim.toISOString().slice(0, 10), dataFim: p.dataFim.toISOString().slice(0, 10),
})), })),
@@ -368,12 +416,30 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }], orderBy: [{ descPct: 'desc' }],
}); });
// Nomes de grupo/subgrupo — UI sempre mostra o nome, nunca só o código
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
? await this.loadGruposProduto()
: [];
const grupoNameMap = new Map(
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
);
const subgrupoNameMap = new Map(
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
);
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
const pautaNameMap = await this.loadPautaNames(idPautas);
return { return {
regras: rows.map((r) => ({ regras: rows.map((r) => ({
id: r.id, id: r.id,
descricao: r.descricao, descricao: r.descricao,
codGrupo: r.codGrupo, codGrupo: r.codGrupo,
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
codSubgrupo: r.codSubgrupo, codSubgrupo: r.codSubgrupo,
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
idPauta: r.idPauta,
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
descPct: Number(r.descPct), descPct: Number(r.descPct),
dataFim: r.dataFim.toISOString().slice(0, 10), dataFim: r.dataFim.toISOString().slice(0, 10),
})), })),

View File

@@ -12,6 +12,7 @@
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1", "@fortawesome/react-fontawesome": "^3.3.1",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@svg-maps/brazil": "^2.0.0",
"@tanstack/react-query": "^5.100.14", "@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8", "@tanstack/react-router": "^1.170.8",
"antd": "^6.4.3", "antd": "^6.4.3",

View File

@@ -1,7 +1,8 @@
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd'; import { useState } from 'react';
import type { TableColumnsType } from 'antd'; import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
import type { RepStats } from '@sar/api-interface'; import type { SupervisorCard } from '@sar/api-interface';
import { useEquipe } from '../../lib/queries/gerente'; import { useSupervisores } from '../../lib/queries/gerente';
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -9,85 +10,118 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
const columns: TableColumnsType<RepStats> = [ function metaColor(pct: number): string {
{ return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
title: 'Representante', }
key: 'rep',
render: (_: unknown, row: RepStats) => ( const MEDALHAS = ['🥇', '🥈', '🥉'];
<Flex vertical gap={0}>
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text> function posicaoLabel(i: number): string {
{row.nomeVendedor && ( return MEDALHAS[i] ?? `${i + 1}º`;
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> }
cód. {row.codVendedor}
</Text> function SupervisorCardView({
)} s,
</Flex> posicao,
), onDetalhar,
}, }: {
{ s: SupervisorCard;
title: 'Pedidos', posicao: number;
dataIndex: 'pedidosMes', onDetalhar: () => void;
width: 90, }) {
align: 'right', const lider = posicao === 0;
className: 'tabular-nums', return (
sorter: (a, b) => a.pedidosMes - b.pedidosMes, <Card
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v), style={lider ? { borderColor: 'var(--jcs-blue)', borderWidth: 2 } : undefined}
}, styles={{ body: { padding: 'var(--space-lg)' } }}
{ >
title: 'Faturamento', <Flex vertical gap={12}>
dataIndex: 'faturamentoMes', {/* Cabeçalho: posição + nome */}
width: 160, <Flex align="center" gap={10}>
align: 'right', <span style={{ fontSize: 22, lineHeight: 1, minWidth: 30, textAlign: 'center' }}>
className: 'tabular-nums', {posicaoLabel(posicao)}
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes, </span>
defaultSortOrder: 'descend', <Flex vertical gap={0} style={{ minWidth: 0, flex: 1 }}>
render: (v: number) => fmt(v), <Text strong ellipsis style={{ fontSize: 'var(--text-lg)' }}>
}, {s.nomeSupervisor ?? `Supervisor cód. ${s.codSupervisor}`}
{ </Text>
title: 'Ticket Medio', <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
dataIndex: 'ticketMedio', cód. {s.codSupervisor} · {s.repsAtivos}/{s.repsTotal} reps ativos
width: 140, </Text>
align: 'right', </Flex>
className: 'tabular-nums',
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 170,
sorter: (a, b) => a.pctMeta - b.pctMeta,
render: (pct: number) =>
pct === 0 ? (
<Text type="secondary">sem meta</Text>
) : (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex> </Flex>
),
}, {/* Faturamento + variação */}
]; <Flex align="baseline" gap={10} wrap>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(s.faturamentoMes)}
</Title>
{s.variacaoPct != null && (
<Text
className="tabular-nums"
style={{
fontSize: 'var(--text-sm)',
color: s.variacaoPct >= 0 ? 'var(--green)' : '#cf1322',
}}
>
{s.variacaoPct >= 0 ? '▲' : '▼'} {s.variacaoPct >= 0 ? '+' : ''}
{s.variacaoPct}% vs. mês ant.
</Text>
)}
</Flex>
{/* Meta do time */}
<div>
<Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Meta do time
</Text>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
</Text>
</Flex>
<Progress
percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
size="small"
strokeColor={metaColor(s.pctMeta)}
showInfo={false}
/>
</div>
{/* Ticket + positivação */}
<Flex justify="space-between" gap={12}>
<Space orientation="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Text strong className="tabular-nums">
{s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'}
</Text>
</Space>
<Space orientation="vertical" size={0} style={{ textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
POSITIVAÇÃO
</Text>
<Text strong className="tabular-nums">
{s.pctPositivacao}%{' '}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
({s.clientesPositivados}/{s.totalClientes})
</Text>
</Text>
</Space>
</Flex>
<Button block onClick={onDetalhar}>
Detalhar
</Button>
</Flex>
</Card>
);
}
export function EquipePage() { export function EquipePage() {
const { data, isLoading } = useEquipe(); const { data, isLoading } = useSupervisores();
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
if (isLoading || !data) {
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 1 }} />
<Skeleton active paragraph={{ rows: 6 }} />
</Flex>
);
}
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
@@ -98,25 +132,41 @@ export function EquipePage() {
Equipe Equipe
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Performance dos representantes &mdash; {mes} Disputa dos supervisores &mdash; {mes}
</Text> </Text>
</Flex> </Flex>
<Card> {isLoading || !data ? (
<Table<RepStats> <Row gutter={[24, 24]}>
rowKey="codVendedor" {[1, 2, 3].map((i) => (
columns={columns} <Col key={i} xs={24} md={12} lg={8}>
dataSource={data.reps} <Card>
pagination={false} <Skeleton active paragraph={{ rows: 4 }} />
size="small" </Card>
locale={{ emptyText: 'Nenhum representante encontrado.' }} </Col>
scroll={{ x: 700 }} ))}
/> </Row>
</Card> ) : data.supervisores.length === 0 ? (
<Card>
<Empty description="Nenhum supervisor com equipe encontrado." />
</Card>
) : (
<>
<Row gutter={[24, 24]}>
{data.supervisores.map((s: SupervisorCard, i: number) => (
<Col key={s.codSupervisor} xs={24} md={12} lg={8}>
<SupervisorCardView s={s} posicao={i} onDetalhar={() => setDetalhe(s)} />
</Col>
))}
</Row>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')} Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
</Text> </Text>
</>
)}
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
</Flex> </Flex>
); );
} }

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 { import {
Button, Button,
Card, Card,
@@ -11,7 +12,9 @@ import {
Skeleton, Skeleton,
Space, Space,
Table, Table,
Tag,
Typography, Typography,
message,
} from 'antd'; } from 'antd';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
@@ -42,6 +45,12 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface'; import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente'; import { useManagerDashboard } from '../../lib/queries/gerente';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
import { apiFetch } from '../../lib/api-client';
import { MapaBrasilCard } from './MapaBrasilCard';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -55,9 +64,6 @@ ChartJS.register(
const { Title, Text } = Typography; 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 { function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
@@ -66,45 +72,6 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
} }
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
const metasGrupoColumns: TableColumnsType<MetaItem> = [ const metasGrupoColumns: TableColumnsType<MetaItem> = [
{ {
title: 'Grupo', title: 'Grupo',
@@ -162,76 +129,33 @@ const metasGrupoColumns: TableColumnsType<MetaItem> = [
}, },
]; ];
const rankingColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
function PositivacaoDiariaCard({ function PositivacaoDiariaCard({
dados, dados,
metaSalva,
periodo, periodo,
periodoLabel, periodoLabel,
isMesAtual, isMesAtual,
}: { }: {
dados: PositivacaoDia[]; dados: PositivacaoDia[];
metaSalva: number;
periodo: Dayjs; periodo: Dayjs;
periodoLabel: string; periodoLabel: string;
isMesAtual: boolean; isMesAtual: boolean;
}) { }) {
const [meta, setMeta] = useState<number>(() => { // Meta salva no servidor (config_empresa) — vale em qualquer dispositivo
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY)); const [meta, setMeta] = useState<number>(metaSalva);
return Number.isFinite(saved) && saved > 0 ? saved : 0; useEffect(() => setMeta(metaSalva), [metaSalva]);
}); const qc = useQueryClient();
const [msg, msgCtx] = message.useMessage();
function changeMeta(v: number | null) { const salvarMeta = useMutation({
const val = v ?? 0; mutationFn: (m: number) =>
setMeta(val); apiFetch('/dashboard/manager/meta-positivacao-dia', { method: 'PUT', body: { meta: m } }),
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val)); 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 // 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(); const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
@@ -318,13 +242,23 @@ function PositivacaoDiariaCard({
min={0} min={0}
size="small" size="small"
value={meta > 0 ? meta : undefined} value={meta > 0 ? meta : undefined}
onChange={changeMeta} onChange={(v) => setMeta(v ?? 0)}
placeholder="—" placeholder="—"
style={{ width: 80 }} style={{ width: 80 }}
/> />
<Button
type="primary"
size="small"
disabled={meta === metaSalva}
loading={salvarMeta.isPending}
onClick={() => salvarMeta.mutate(meta)}
>
Salvar
</Button>
</Flex> </Flex>
} }
> >
{msgCtx}
<div style={{ height: 280 }}> <div style={{ height: 280 }}>
<Chart type="bar" data={chartData} options={options} /> <Chart type="bar" data={chartData} options={options} />
</div> </div>
@@ -371,6 +305,7 @@ export function GerPainel() {
rankingReps, rankingReps,
positivacaoReps, positivacaoReps,
positivacaoDiaria, positivacaoDiaria,
faturamentoPorUf,
syncedAt, syncedAt,
} = data; } = data;
@@ -419,155 +354,179 @@ export function GerPainel() {
</Flex> </Flex>
</Flex> </Flex>
{/* KPIs linha 1 */} {/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} xl={12}>
<Card> <Flex vertical gap={16} style={{ height: '100%' }}>
<Flex vertical gap={4}> <Row gutter={[16, 16]} style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Col xs={24} sm={12} style={{ display: 'flex' }}>
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()} <Card style={{ flex: 1 }}>
</Text> <Flex vertical gap={4}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums"> <Flex justify="space-between" align="center">
{fmt(faturamentoMes)} <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
</Title> FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
<FontAwesomeIcon </Text>
icon={faFileInvoiceDollar} <FontAwesomeIcon
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} icon={faFileInvoiceDollar}
/> style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
</Flex> />
</Card> </Flex>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(faturamentoMes)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
{isMesAtual ? 'mês' : 'período'}
</Text>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<FontAwesomeIcon
icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{metaTotal > 0 ? fmt(metaTotal) : '—'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{metaTotal > 0
? `soma das metas por representante`
: 'sem meta cadastrada no ERP'}
</Text>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
ATINGIMENTO DA META
</Text>
<FontAwesomeIcon
icon={faArrowTrendUp}
style={{ color: metaCorColor, opacity: 0.7 }}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
className="tabular-nums"
>
{metaTotal > 0 ? `${pctMeta}%` : '—'}
</Title>
<Progress
percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
strokeColor={metaCorColor}
showInfo={false}
size="small"
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text>
<FontAwesomeIcon
icon={faChartBar}
style={{
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
opacity: 0.5,
}}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums"
>
{metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
</Title>
{metaTotal === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
sem meta cadastrada no ERP
</Text>
) : superouMeta > 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta
</Text>
) : vendaPorDia > 0 ? (
<Text
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
>
{fmt(vendaPorDia)}/dia &mdash; {diasRestantes} dia
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
</Text>
) : diasRestantes === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
período encerrado
</Text>
) : null}
</Flex>
</Card>
</Col>
</Row>
{/* Indicadores secundários */}
<Card styles={{ body: { padding: '12px 24px' } }}>
<Flex justify="space-around" align="center" wrap="wrap" gap={16}>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
{fmt(ticketMedio)}
</Text>
</Flex>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
REPS COM PEDIDOS
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
<FontAwesomeIcon
icon={faUsers}
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
/>
{totalReps}
</Text>
</Flex>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
PROMOÇÕES ATIVAS
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
<FontAwesomeIcon
icon={faTags}
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
/>
{promocoesAtivas}
</Text>
</Flex>
</Flex>
</Card>
</Flex>
</Col> </Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} xl={12}>
<Card> <MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes}
</Title>
<FontAwesomeIcon
icon={faChartBar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(ticketMedio)}
</Title>
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PROMOÇÕES ATIVAS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{promocoesAtivas}
</Title>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col> </Col>
</Row> </Row>
{/* KPIs linha 2 — Meta */}
{metaTotal > 0 && (
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(metaTotal)}
</Title>
<FontAwesomeIcon
icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
ATINGIMENTO DA META
</Text>
<FontAwesomeIcon
icon={faArrowTrendUp}
style={{ color: metaCorColor, opacity: 0.7 }}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: metaCorColor }}
className="tabular-nums"
>
{pctMeta}%
</Title>
<Progress
percent={Math.min(pctMeta, 100)}
strokeColor={metaCorColor}
showInfo={false}
size="small"
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text>
<Title
level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums"
>
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
</Title>
{superouMeta > 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta
</Text>
) : vendaPorDia > 0 ? (
<Text
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
>
{fmt(vendaPorDia)}/dia &mdash; {diasRestantes} dia
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
</Text>
) : diasRestantes === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
período encerrado
</Text>
) : null}
</Flex>
</Card>
</Col>
</Row>
)}
{/* Metas por Grupo */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
<Card <Card
@@ -618,7 +577,7 @@ export function GerPainel() {
> >
<Table<RankingRep> <Table<RankingRep>
rowKey="codVendedor" rowKey="codVendedor"
columns={rankingColumns} columns={rankingRepColumns}
dataSource={rankingReps} dataSource={rankingReps}
pagination={false} pagination={false}
size="small" size="small"
@@ -629,6 +588,7 @@ export function GerPainel() {
{/* Positivação Diária */} {/* Positivação Diária */}
<PositivacaoDiariaCard <PositivacaoDiariaCard
dados={positivacaoDiaria} dados={positivacaoDiaria}
metaSalva={data.metaPositivacaoDia ?? 0}
periodo={periodo} periodo={periodo}
periodoLabel={periodoLabel} periodoLabel={periodoLabel}
isMesAtual={isMesAtual} isMesAtual={isMesAtual}
@@ -643,14 +603,25 @@ export function GerPainel() {
</Space> </Space>
} }
extra={ extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Flex align="center" gap={12}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação {positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) > 0 && (
</Text> <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> <Table<PositivacaoRep>
rowKey="codVendedor" rowKey="codVendedor"
columns={positivacaoColumns} columns={positivacaoRepColumns}
dataSource={positivacaoReps} dataSource={positivacaoReps}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }} pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
size="small" size="small"

View File

@@ -0,0 +1,360 @@
import { useMemo, useState } from 'react';
import { Button, Card, Empty, Flex, Modal, Progress, Table, Tag, Tooltip, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMapLocationDot, faUserTie } from '@fortawesome/free-solid-svg-icons';
import type { UfFaturamento, UfRep } from '@sar/api-interface';
import brazilMap from '@svg-maps/brazil';
const { Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// R$ compacto para a lista lateral (R$ 12,4 mil / R$ 1,2 mi)
function fmtCompacto(v: number): string {
if (v >= 1_000_000)
return `R$ ${(v / 1_000_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mi`;
if (v >= 1_000)
return `R$ ${(v / 1_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mil`;
return fmt(v);
}
// Escala sequencial (um matiz, claro→escuro) derivada do azul JCS.
const SEQ_STEPS = ['#dbe9f6', '#aecde9', '#7BA7D7', '#3d74b8', '#004a99'];
const COR_SEM_VENDA = '#edf0f3';
function corDaUf(valor: number, max: number): string {
if (valor <= 0 || max <= 0) return COR_SEM_VENDA;
const idx = Math.min(SEQ_STEPS.length - 1, Math.floor((valor / max) * SEQ_STEPS.length));
return SEQ_STEPS[idx] ?? COR_SEM_VENDA;
}
const NOME_UF = new Map(brazilMap.locations.map((l) => [l.id.toUpperCase(), l.name]));
const repColumns: TableColumnsType<UfRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, r: UfRep) => (
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faUserTie} style={{ color: 'var(--jcs-blue)', opacity: 0.45 }} />
{r.nomeVendedor ?? `Cód. ${r.codVendedor}`}
</Flex>
),
ellipsis: true,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientes',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
];
function detalheColumns(): TableColumnsType<UfFaturamento> {
return [
{
title: 'Estado',
key: 'uf',
render: (_: unknown, r: UfFaturamento) => (
<Flex align="center" gap={8}>
<Tag style={{ margin: 0, fontWeight: 700, width: 40, textAlign: 'center' }}>{r.uf}</Tag>
<Text>{NOME_UF.get(r.uf) ?? 'Sem UF cadastrada'}</Text>
</Flex>
),
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientes',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => (
<Text strong className="tabular-nums">
{fmt(v)}
</Text>
),
},
{
title: 'Participação',
dataIndex: 'pct',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor="var(--jcs-blue)"
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
{pct.toLocaleString('pt-BR')}%
</Text>
</Flex>
),
},
];
}
export function MapaBrasilCard({
dados,
periodoLabel,
}: {
dados: UfFaturamento[];
periodoLabel: string;
}) {
const [detalheAberto, setDetalheAberto] = useState(false);
const [ufsExpandidas, setUfsExpandidas] = useState<string[]>([]);
const [ufHover, setUfHover] = useState<string | null>(null);
const porUf = useMemo(() => new Map(dados.map((d) => [d.uf, d])), [dados]);
const max = dados.reduce((m, d) => Math.max(m, d.faturamento), 0);
const comVenda = dados.filter((d) => d.faturamento > 0 && d.uf !== 'ND');
const topUfs = comVenda.slice(0, 8);
const semUf = dados.find((d) => d.uf === 'ND');
const abrirDetalhe = (uf?: string) => {
const primeira = comVenda[0]?.uf;
setUfsExpandidas(uf ? [uf] : primeira ? [primeira] : []);
setDetalheAberto(true);
};
return (
<Card
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
Faturamento por Estado
</Flex>
}
extra={
<Button type="primary" ghost size="small" onClick={() => abrirDetalhe()}>
Detalhar
</Button>
}
style={{ height: '100%' }}
styles={{ body: { paddingTop: 12 } }}
>
{dados.length === 0 ? (
<Empty description="Nenhuma venda no período." image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Flex gap={16} wrap="wrap">
{/* Mapa choropleth */}
<Flex vertical gap={8} style={{ flex: '1 1 260px', minWidth: 240 }}>
<svg
viewBox={brazilMap.viewBox}
role="img"
aria-label={`Mapa do Brasil com faturamento por estado — ${periodoLabel}`}
style={{ width: '100%', height: 'auto', maxHeight: 330 }}
>
{brazilMap.locations.map((loc) => {
const uf = loc.id.toUpperCase();
const dado = porUf.get(uf);
const valor = dado?.faturamento ?? 0;
const hover = ufHover === uf;
return (
<Tooltip
key={loc.id}
title={
<Flex vertical gap={2}>
<Text strong style={{ color: '#fff' }}>
{loc.name} ({uf})
</Text>
{dado ? (
<>
<span>
{fmt(valor)} · {dado.pct.toLocaleString('pt-BR')}% do total
</span>
<span>
{dado.pedidos} pedido{dado.pedidos !== 1 ? 's' : ''} · {dado.clientes}{' '}
cliente{dado.clientes !== 1 ? 's' : ''}
</span>
</>
) : (
<span>Sem vendas no período</span>
)}
</Flex>
}
>
<path
d={loc.path}
fill={corDaUf(valor, max)}
stroke={hover ? 'var(--jcs-blue-hover)' : '#fff'}
strokeWidth={hover ? 2 : 1}
style={{ cursor: dado ? 'pointer' : 'default', transition: 'fill 0.15s' }}
onMouseEnter={() => setUfHover(uf)}
onMouseLeave={() => setUfHover(null)}
onClick={() => dado && abrirDetalhe(uf)}
/>
</Tooltip>
);
})}
</svg>
{/* Legenda da escala */}
<Flex align="center" gap={6} style={{ paddingInline: 4 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sem venda
</Text>
<span
style={{
width: 14,
height: 10,
background: COR_SEM_VENDA,
border: '1px solid #d9d9d9',
borderRadius: 2,
}}
/>
<span style={{ width: 8 }} />
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Menor
</Text>
{SEQ_STEPS.map((c) => (
<span key={c} style={{ width: 14, height: 10, background: c, borderRadius: 2 }} />
))}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Maior
</Text>
</Flex>
</Flex>
{/* Ranking de estados */}
<Flex vertical gap={6} style={{ flex: '1 1 190px', minWidth: 180 }}>
{topUfs.map((d, i) => (
<Flex
key={d.uf}
align="center"
gap={8}
style={{
padding: '4px 8px',
borderRadius: 6,
background: ufHover === d.uf ? 'var(--jcs-blue-light)' : undefined,
cursor: 'pointer',
}}
onMouseEnter={() => setUfHover(d.uf)}
onMouseLeave={() => setUfHover(null)}
onClick={() => abrirDetalhe(d.uf)}
>
<Text
type="secondary"
className="tabular-nums"
style={{ width: 16, fontSize: 'var(--text-xs)' }}
>
{i + 1}º
</Text>
<span
style={{
width: 10,
height: 10,
borderRadius: 2,
background: corDaUf(d.faturamento, max),
border: '1px solid rgba(0,0,0,0.08)',
flexShrink: 0,
}}
/>
<Text strong style={{ width: 28 }}>
{d.uf}
</Text>
<Text
className="tabular-nums"
style={{ marginLeft: 'auto', fontSize: 'var(--text-sm)' }}
>
{fmtCompacto(d.faturamento)}
</Text>
<Text
type="secondary"
className="tabular-nums"
style={{ width: 44, textAlign: 'right', fontSize: 'var(--text-xs)' }}
>
{d.pct.toLocaleString('pt-BR')}%
</Text>
</Flex>
))}
{comVenda.length > topUfs.length && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
+ {comVenda.length - topUfs.length} outro
{comVenda.length - topUfs.length !== 1 ? 's' : ''} estado
{comVenda.length - topUfs.length !== 1 ? 's' : ''} ver Detalhar
</Text>
)}
{semUf && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
{fmtCompacto(semUf.faturamento)} sem UF cadastrada
</Text>
)}
</Flex>
</Flex>
)}
{/* Detalhe por estado → representantes */}
<Modal
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
Faturamento por Estado &mdash; {periodoLabel}
</Flex>
}
open={detalheAberto}
onCancel={() => setDetalheAberto(false)}
footer={null}
width={900}
centered
>
<Table<UfFaturamento>
rowKey="uf"
columns={detalheColumns()}
dataSource={dados}
pagination={false}
size="small"
scroll={{ y: 420 }}
expandable={{
expandedRowKeys: ufsExpandidas,
onExpandedRowsChange: (keys) => setUfsExpandidas([...keys] as string[]),
expandedRowRender: (r) => (
<Table<UfRep>
rowKey="codVendedor"
columns={repColumns}
dataSource={r.reps}
pagination={false}
size="small"
/>
),
}}
locale={{ emptyText: 'Nenhuma venda no período.' }}
/>
</Modal>
</Card>
);
}

View File

@@ -11,6 +11,7 @@ import {
Select, Select,
Skeleton, Skeleton,
Space, Space,
Switch,
Table, Table,
Tabs, Tabs,
Tag, Tag,
@@ -23,17 +24,9 @@ import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
AlcadaDescontoItem,
Promocao,
RegraDesconto,
RepStats,
GrupoProdutoItem,
} from '@sar/api-interface';
import { import {
usePoliticasDescontos,
usePromocoes, usePromocoes,
useUpsertDesconto,
useCreatePromocao, useCreatePromocao,
useUpdatePromocao, useUpdatePromocao,
useDeletePromocao, useDeletePromocao,
@@ -44,140 +37,81 @@ import {
useUpdateRegraDesconto, useUpdateRegraDesconto,
useDeleteRegraDesconto, useDeleteRegraDesconto,
} from '../../lib/queries/gerente'; } from '../../lib/queries/gerente';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
const { Title, Text } = Typography; // ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
// ─── Tab Descontos ──────────────────────────────────────────────────────────── 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());
function TabDescontos() { const opts = (data?.data ?? []).map((p) => ({
const { data, isLoading } = usePoliticasDescontos(); value: p.codigo.trim(),
const upsert = useUpsertDesconto(); label: `${p.codigo.trim()}${p.descricao.trim()}`,
const [editingKey, setEditingKey] = useState<string | null>(null); }));
const [editValue, setEditValue] = useState<number>(0); const selecionados = multiple
const [msg, msgCtx] = message.useMessage(); ? ((value as string[] | undefined) ?? [])
: value
function startEdit(row: AlcadaDescontoItem) { ? [value as string]
setEditingKey(`${row.codVendedor}-${row.codGrupo}`); : [];
setEditValue(row.limitePerc); const merged = [...opts];
for (const v of selecionados) {
if (!merged.some((o) => o.value === v)) merged.push({ value: v, label: labels.get(v) ?? v });
} }
async function saveEdit(row: AlcadaDescontoItem) {
try {
await upsert.mutateAsync({
codVendedor: row.codVendedor,
codGrupo: row.codGrupo,
limitePerc: editValue,
});
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
setEditingKey(null);
void msg.success('Limite atualizado');
}
const columns: TableColumnsType<AlcadaDescontoItem> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: AlcadaDescontoItem) =>
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Grupo',
dataIndex: 'codGrupo',
width: 100,
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
},
{
title: 'Limite de Desconto',
dataIndex: 'limitePerc',
width: 200,
render: (v: number, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<InputNumber
value={editValue}
onChange={(val) => setEditValue(val ?? 0)}
min={0}
max={100}
suffix="%"
size="small"
style={{ width: 100 }}
autoFocus
/>
);
}
return <Text className="tabular-nums">{v}%</Text>;
},
},
{
title: '',
width: 120,
render: (_: unknown, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<Space>
<Button
size="small"
type="primary"
loading={upsert.isPending}
onClick={() => void saveEdit(row)}
>
Salvar
</Button>
<Button size="small" onClick={() => setEditingKey(null)}>
Cancelar
</Button>
</Space>
);
}
return (
<Button
size="small"
icon={<FontAwesomeIcon icon={faPen} />}
onClick={() => startEdit(row)}
>
Editar
</Button>
);
},
},
];
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
return ( return (
<> <Select
{msgCtx} mode={multiple ? 'multiple' : undefined}
<Table<AlcadaDescontoItem> showSearch
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`} allowClear
columns={columns} filterOption={false}
dataSource={data.descontos} onSearch={setQ}
pagination={false} loading={isFetching}
size="small" options={merged}
locale={{ emptyText: 'Nenhum limite configurado.' }} value={value}
/> onChange={(vals) => {
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}> setLabels((prev) => {
Limite 0 = sem restricao de desconto para este representante. const next = new Map(prev);
</Text> 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;
// ─── Tab Promocoes ──────────────────────────────────────────────────────────── // ─── Tab Promocoes ────────────────────────────────────────────────────────────
interface PromocaoForm { interface PromocaoForm {
descricao: string; descricao: string;
codProdutos?: string[];
grpProds?: string[];
codProduto?: string; codProduto?: string;
grpProd?: string; grpProd?: string;
descPct: number; descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs]; periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean;
} }
function TabPromocoes() { function TabPromocoes() {
const { data, isLoading } = usePromocoes(); const { data, isLoading } = usePromocoes();
const { data: gruposData } = useGruposProduto();
const createMutation = useCreatePromocao(); const createMutation = useCreatePromocao();
const updateMutation = useUpdatePromocao(); const updateMutation = useUpdatePromocao();
const deleteMutation = useDeletePromocao(); const deleteMutation = useDeletePromocao();
@@ -186,6 +120,18 @@ function TabPromocoes() {
const [editTarget, setEditTarget] = useState<Promocao | null>(null); const [editTarget, setEditTarget] = useState<Promocao | null>(null);
const [msg, msgCtx] = message.useMessage(); 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() { function openCreate() {
setEditTarget(null); setEditTarget(null);
form.resetFields(); form.resetFields();
@@ -200,27 +146,49 @@ function TabPromocoes() {
grpProd: p.grpProd ?? undefined, grpProd: p.grpProd ?? undefined,
descPct: p.descPct, descPct: p.descPct,
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)], periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
ativa: p.ativa,
}); });
setModalOpen(true); 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) { async function handleSubmit(values: PromocaoForm) {
const [inicio, fim] = values.periodo; const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codProduto: values.codProduto || undefined,
grpProd: values.grpProd || undefined,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try { try {
if (editTarget) { if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body }); await updateMutation.mutateAsync({
id: editTarget.id,
body: {
descricao: values.descricao,
codProduto: values.codProduto ?? null,
grpProd: values.grpProd ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
ativa: values.ativa ?? true,
},
});
void msg.success('Promocao atualizada'); void msg.success('Promocao atualizada');
} else { } else {
await createMutation.mutateAsync(body); const res = (await createMutation.mutateAsync({
void msg.success('Promocao criada'); 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 { } catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
@@ -280,6 +248,19 @@ function TabPromocoes() {
return <Tag color="green">Ativa</Tag>; 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: '', title: '',
width: 100, width: 100,
@@ -337,7 +318,8 @@ function TabPromocoes() {
onOk={() => void form.validateFields().then(handleSubmit)} onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'} okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending} confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520} width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered centered
> >
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@@ -349,14 +331,49 @@ function TabPromocoes() {
<Input placeholder="Ex.: Promocao de inverno" /> <Input placeholder="Ex.: Promocao de inverno" />
</Form.Item> </Form.Item>
<Flex gap={16}> {editTarget ? (
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}> <Flex gap={16}>
<Input placeholder="Opcional" /> <Form.Item name="codProduto" label="Produto" style={{ flex: 1 }}>
</Form.Item> <ProdutoSelect />
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}> </Form.Item>
<Input placeholder="Opcional" /> <Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
</Form.Item> <Select
</Flex> 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 <Form.Item
name="descPct" name="descPct"
@@ -388,16 +405,22 @@ function TabPromocoes() {
interface RegraForm { interface RegraForm {
descricao: string; descricao: string;
codVendedor?: number; codVendedor?: number;
codGrupos?: number[];
codSubgrupos?: number[];
idPautas?: number[];
codGrupo?: number; codGrupo?: number;
codSubgrupo?: number; codSubgrupo?: number;
idPauta?: number;
descPct: number; descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs]; periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean;
} }
function TabRegras() { function TabRegras() {
const { data, isLoading } = useRegrasDesconto(); const { data, isLoading } = useRegrasDesconto();
const { data: equipe } = useEquipe(); const { data: equipe } = useEquipe();
const { data: gruposData } = useGruposProduto(); const { data: gruposData } = useGruposProduto();
const { data: pautas } = usePautas();
const createMutation = useCreateRegraDesconto(); const createMutation = useCreateRegraDesconto();
const updateMutation = useUpdateRegraDesconto(); const updateMutation = useUpdateRegraDesconto();
const deleteMutation = useDeleteRegraDesconto(); const deleteMutation = useDeleteRegraDesconto();
@@ -406,27 +429,43 @@ function TabRegras() {
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null); const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
const [msg, msgCtx] = message.useMessage(); const [msg, msgCtx] = message.useMessage();
const grupoSelecionado = Form.useWatch('codGrupo', form); const grupoSelecionado = Form.useWatch('codGrupo', form);
const gruposSelecionados = Form.useWatch('codGrupos', form);
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({ const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
value: r.codVendedor, value: r.codVendedor,
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`, label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
})); }));
// Pautas de preços — rótulo com código + descrição (busca encontra pelos dois)
const pautaOptions = (pautas ?? []).map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
}));
// 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 grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
const grupoMap = new Map<number, { value: number; label: string }>(); const grupoMap = new Map<number, { value: number; label: string }>();
const subgrupoMap = new Map<number, { value: number; label: string }>(); const subgrupoMap = new Map<number, { value: number; label: string }>();
for (const g of grupoRows) { for (const g of grupoRows) {
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) { 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 ( if (
g.codSubgrupo != null && g.codSubgrupo != null &&
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) && (filtroGrupos.size === 0 || (g.codGrupo != null && filtroGrupos.has(g.codGrupo))) &&
!subgrupoMap.has(g.codSubgrupo) !subgrupoMap.has(g.codSubgrupo)
) { ) {
subgrupoMap.set(g.codSubgrupo, { subgrupoMap.set(g.codSubgrupo, {
value: g.codSubgrupo, value: g.codSubgrupo,
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`, label: `${g.codSubgrupo} ${g.subgrupo ?? 'Sem nome'}`,
}); });
} }
} }
@@ -446,30 +485,54 @@ function TabRegras() {
codVendedor: r.codVendedor ?? undefined, codVendedor: r.codVendedor ?? undefined,
codGrupo: r.codGrupo ?? undefined, codGrupo: r.codGrupo ?? undefined,
codSubgrupo: r.codSubgrupo ?? undefined, codSubgrupo: r.codSubgrupo ?? undefined,
idPauta: r.idPauta ?? undefined,
descPct: r.descPct, descPct: r.descPct,
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)], periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
ativa: r.ativa,
}); });
setModalOpen(true); 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) { async function handleSubmit(values: RegraForm) {
const [inicio, fim] = values.periodo; const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null,
codSubgrupo: values.codSubgrupo ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try { try {
if (editTarget) { if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body }); await updateMutation.mutateAsync({
id: editTarget.id,
body: {
descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null,
codSubgrupo: values.codSubgrupo ?? null,
idPauta: values.idPauta ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
ativa: values.ativa ?? true,
},
});
void msg.success('Regra atualizada'); void msg.success('Regra atualizada');
} else { } else {
await createMutation.mutateAsync(body); const res = (await createMutation.mutateAsync({
void msg.success('Regra criada'); descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
idPautas: values.idPautas?.length ? values.idPautas : 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 { } catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
@@ -505,7 +568,7 @@ function TabRegras() {
}, },
{ {
title: 'Grupo / Subgrupo', title: 'Grupo / Subgrupo',
width: 220, width: 200,
render: (_: unknown, row: RegraDesconto) => { render: (_: unknown, row: RegraDesconto) => {
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>; if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
const partes = [ const partes = [
@@ -515,6 +578,16 @@ function TabRegras() {
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>; return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
}, },
}, },
{
title: 'Pauta',
width: 160,
render: (_: unknown, row: RegraDesconto) =>
row.idPauta == null ? (
<Tag>Todas</Tag>
) : (
<Text style={{ fontSize: 'var(--text-sm)' }}>{row.pauta ?? `Pauta ${row.idPauta}`}</Text>
),
},
{ {
title: 'Desconto', title: 'Desconto',
dataIndex: 'descPct', dataIndex: 'descPct',
@@ -541,6 +614,19 @@ function TabRegras() {
return <Tag color="green">Ativa</Tag>; 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: '', title: '',
width: 100, width: 100,
@@ -591,8 +677,8 @@ function TabRegras() {
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }} locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
/> />
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
o desconto automaticamente nos pedidos durante a vigencia. aplica o desconto automaticamente nos pedidos durante a vigencia.
</Text> </Text>
<Modal <Modal
@@ -602,7 +688,8 @@ function TabRegras() {
onOk={() => void form.validateFields().then(handleSubmit)} onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'} okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending} confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520} width={760}
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
centered centered
> >
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@@ -624,27 +711,88 @@ function TabRegras() {
/> />
</Form.Item> </Form.Item>
<Flex gap={16}> {editTarget ? (
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}> <>
<Select <Flex gap={16}>
options={grupoOptions} <Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
placeholder="Todos os grupos" <Select
allowClear options={grupoOptions}
showSearch placeholder="Todos os grupos"
optionFilterProp="label" allowClear
onChange={() => form.setFieldValue('codSubgrupo', undefined)} showSearch
/> optionFilterProp="label"
</Form.Item> onChange={() => form.setFieldValue('codSubgrupo', undefined)}
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}> />
<Select </Form.Item>
options={subgrupoOptions} <Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
placeholder="Todos os subgrupos" <Select
allowClear options={subgrupoOptions}
showSearch placeholder="Todos os subgrupos"
optionFilterProp="label" allowClear
/> showSearch
</Form.Item> optionFilterProp="label"
</Flex> />
</Form.Item>
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
<Switch />
</Form.Item>
</Flex>
<Form.Item name="idPauta" label="Pauta de precos">
<Select
options={pautaOptions}
placeholder="Todas as pautas"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</>
) : (
<>
<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="idPautas"
label="Pautas de precos"
extra="Selecione 1 ou mais pautas — uma regra por pauta, aplicada a todos os produtos dela"
>
<Select
mode="multiple"
options={pautaOptions}
placeholder="Todas as pautas"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</>
)}
<Form.Item <Form.Item
name="descPct" name="descPct"
@@ -675,11 +823,6 @@ function TabRegras() {
export function PoliticasPage() { export function PoliticasPage() {
const items = [ const items = [
{
key: 'descontos',
label: 'Desconto por Representante',
children: <TabDescontos />,
},
{ {
key: 'promocoes', key: 'promocoes',
label: 'Promocoes', label: 'Promocoes',
@@ -699,7 +842,7 @@ export function PoliticasPage() {
Politicas Comerciais Politicas Comerciais
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Limites de desconto e promocoes com vigencia Promocoes e regras de desconto com vigencia
</Text> </Text>
</Flex> </Flex>

View File

@@ -0,0 +1,164 @@
import { Card, Col, Flex, Modal, Progress, Row, Space, Spin, Table, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAddressCard, faRankingStar } from '@fortawesome/free-solid-svg-icons';
import type { PositivacaoRep, RankingRep, SupervisorCard } from '@sar/api-interface';
import { useSupervisorDashboardByCod } from '../../lib/queries/dashboard';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
const { Title, Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function metaColor(pct: number): string {
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
}
// Detalhe/análise do time de um supervisor — aberto pelo gerente na aba Equipe.
// Reusa /dashboard/supervisor escopado por codSupervisor e as mesmas tabelas do
// painel do supervisor (ranking + positivação).
export function SupervisorDetalheModal({
supervisor,
onClose,
}: {
supervisor: SupervisorCard | null;
onClose: () => void;
}) {
const { data, isLoading } = useSupervisorDashboardByCod(supervisor?.codSupervisor ?? null);
const titulo = supervisor
? `${supervisor.nomeSupervisor ?? `Supervisor cód. ${supervisor.codSupervisor}`}`
: '';
return (
<Modal
open={supervisor !== null}
onCancel={onClose}
footer={null}
width={900}
centered
destroyOnHidden
title={
<Space>
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
{titulo}
</Space>
}
>
{isLoading || !data || !supervisor ? (
<Spin style={{ display: 'block', margin: '48px auto' }} />
) : (
<Flex vertical gap={20}>
{/* KPIs do time no mês */}
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
FATURAMENTO NO MÊS
</Text>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{fmt(data.equipeMes.faturamento)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{data.equipeMes.pedidos} pedido{data.equipeMes.pedidos !== 1 ? 's' : ''} ·{' '}
{supervisor.repsAtivos}/{supervisor.repsTotal} reps ativos
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
META DO TIME
</Text>
{data.equipeMes.metaTotal > 0 ? (
<>
<Flex align="baseline" gap={6}>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{data.equipeMes.pctMeta}%
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
de {fmt(data.equipeMes.metaTotal)}
</Text>
</Flex>
<Progress
percent={Math.min(data.equipeMes.pctMeta, 100)}
size="small"
strokeColor={metaColor(data.equipeMes.pctMeta)}
showInfo={false}
/>
</>
) : (
<Text type="secondary">Sem meta cadastrada.</Text>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{fmt(data.equipeMes.ticketMedio)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
positivação {supervisor.pctPositivacao}% ({supervisor.clientesPositivados}/
{supervisor.totalClientes})
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Ranking dos reps do time */}
<div>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontAwesomeIcon
icon={faRankingStar}
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
/>
Ranking dos representantes
</Text>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingRepColumns}
dataSource={data.rankingReps}
pagination={false}
size="small"
scroll={{ x: 640 }}
locale={{ emptyText: 'Nenhum pedido do time no mês.' }}
/>
</div>
{/* Positivação dos reps do time */}
<div>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontAwesomeIcon
icon={faAddressCard}
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
/>
Positivação de clientes
</Text>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoRepColumns}
dataSource={data.positivacaoReps}
pagination={false}
size="small"
scroll={{ x: 640 }}
locale={{ emptyText: 'Nenhum representante com carteira no time.' }}
/>
</div>
</Flex>
)}
</Modal>
);
}

View File

@@ -1,398 +0,0 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Flex,
Row,
Skeleton,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
ArrowLeftOutlined,
ExclamationCircleOutlined,
FireOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import type { CarteiraCliente } from '@sar/api-interface';
import { useReportCarteira } from '../../lib/queries/reports';
const { Title, Text } = Typography;
function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v + 'T00:00:00').toLocaleDateString('pt-BR');
}
type Filtro = 'todos' | 'ativos' | 'risco' | 'inativos' | 'semPedido';
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedido</Text>} />;
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
return (
<Badge
color="#ef4444"
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
/>
);
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
return (
<Badge
color="#f59e0b"
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Em risco 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
function InsightCard({
icon,
cor,
titulo,
descricao,
}: {
icon: React.ReactNode;
cor: string;
titulo: string;
descricao: string;
}) {
return (
<Card
styles={{ body: { padding: '14px 18px' } }}
style={{ borderLeft: `4px solid ${cor}`, borderRadius: 8 }}
>
<Flex gap={12} align="flex-start">
<span style={{ color: cor, fontSize: 18, marginTop: 2 }}>{icon}</span>
{/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao
embutem razão social e valores que podem não ter ponto de quebra. */}
<div style={{ minWidth: 0 }}>
<Text strong style={{ fontSize: 13, color: '#1e293b' }}>
{titulo}
</Text>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{descricao}
</Text>
</div>
</div>
</Flex>
</Card>
);
}
export function CarteirePage() {
const navigate = useNavigate();
const { data, isLoading, isError, error } = useReportCarteira();
const [filtro, setFiltro] = useState<Filtro>('todos');
const clientes = data?.data ?? [];
const ativos = clientes.filter(
(c) => c.totalPedidos > 0 && (c.diasSemPedido == null || c.diasSemPedido < 30),
);
const emRisco = clientes.filter(
(c) => c.diasSemPedido != null && c.diasSemPedido >= 30 && c.diasSemPedido < 60,
);
const inativos = clientes.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60);
const semPedido = clientes.filter((c) => c.totalPedidos === 0);
const filtered = (() => {
if (filtro === 'ativos') return ativos;
if (filtro === 'risco') return emRisco;
if (filtro === 'inativos') return inativos;
if (filtro === 'semPedido') return semPedido;
return clientes;
})();
// Insights calculados
const top5Fat = clientes
.slice()
.sort((a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal))
.slice(0, 5);
const top5Pct = top5Fat.reduce((acc, c) => acc + c.participacaoPct, 0);
const maiorCliEmRisco = emRisco.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const maiorCliInativo = inativos.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const insights: { icon: React.ReactNode; cor: string; titulo: string; descricao: string }[] = [];
if (top5Pct >= 60) {
insights.push({
icon: <ExclamationCircleOutlined />,
cor: '#f59e0b',
titulo: `Concentração: top 5 clientes = ${top5Pct.toFixed(0)}% do faturamento`,
descricao: 'Risco alto de dependência. Considere expandir outros clientes da carteira.',
});
}
if (maiorCliEmRisco) {
const nome =
maiorCliEmRisco.razao ?? maiorCliEmRisco.nome ?? `Cliente ${maiorCliEmRisco.idCliente}`;
insights.push({
icon: <WarningOutlined />,
cor: '#f59e0b',
titulo: `${nome} está ${maiorCliEmRisco.diasSemPedido}d sem comprar`,
descricao: `Faturamento histórico: ${fmt(maiorCliEmRisco.faturamentoTotal)} — priorize o contato.`,
});
}
if (maiorCliInativo) {
const nome =
maiorCliInativo.razao ?? maiorCliInativo.nome ?? `Cliente ${maiorCliInativo.idCliente}`;
insights.push({
icon: <FireOutlined />,
cor: '#ef4444',
titulo: `${nome} inativo há ${maiorCliInativo.diasSemPedido}d`,
descricao: `Era um cliente de ${fmt(maiorCliInativo.faturamentoTotal)} — vale reativar.`,
});
}
if (semPedido.length > 0) {
insights.push({
icon: <RiseOutlined />,
cor: '#003B8E',
titulo: `${semPedido.length} cliente${semPedido.length > 1 ? 's' : ''} sem nenhum pedido`,
descricao: 'Potencial inexplorado na sua carteira. Primeira visita pode gerar receita nova.',
});
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space orientation="vertical" size={0}>
<Text
strong
style={{ fontSize: 13, cursor: 'pointer', color: '#003B8E' }}
onClick={() => navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })}
>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
{c.razao && c.nome && (
<Text type="secondary" style={{ fontSize: 11 }}>
{c.nome}
</Text>
)}
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 140,
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
},
{
title: 'Último Pedido',
dataIndex: 'ultimoPedido',
width: 130,
render: (v: string | null) => (
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
),
sorter: (a, b) => (a.ultimoPedido ?? '').localeCompare(b.ultimoPedido ?? ''),
},
{
title: 'Dias Sem Compra',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999),
},
{
title: 'Faturado (total)',
dataIndex: 'faturamentoTotal',
width: 150,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.faturamentoTotal) - Number(b.faturamentoTotal),
defaultSortOrder: 'descend' as const,
},
{
title: 'Participação',
dataIndex: 'participacaoPct',
width: 110,
align: 'right' as const,
render: (v: number) => (
<Tag
color={v >= 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'}
style={{ borderRadius: 20, fontWeight: 600 }}
>
{v.toFixed(1)}%
</Tag>
),
sorter: (a, b) => a.participacaoPct - b.participacaoPct,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: '#475569' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 80,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
sorter: (a, b) => a.totalPedidos - b.totalPedidos,
},
];
const filtros: { key: Filtro; label: string; count: number; cor: string }[] = [
{ key: 'todos', label: 'Todos', count: clientes.length, cor: '#64748b' },
{ key: 'ativos', label: 'Ativos', count: ativos.length, cor: '#22c55e' },
{ key: 'risco', label: 'Em risco (30d+)', count: emRisco.length, cor: '#f59e0b' },
{ key: 'inativos', label: 'Inativos (60d+)', count: inativos.length, cor: '#ef4444' },
{ key: 'semPedido', label: 'Sem pedido', count: semPedido.length, cor: '#94A3B8' },
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Cabeçalho */}
<Flex align="center" gap={12} style={{ marginBottom: 24 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate({ to: '/clientes' })}
type="text"
style={{ color: '#64748b' }}
/>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
<TeamOutlined style={{ marginRight: 8 }} />
Carteira de Clientes
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
Visão completa da sua carteira com indicadores de saúde e oportunidades
</Text>
</div>
</Flex>
{isError && (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
style={{ marginBottom: 24 }}
/>
)}
{isLoading ? (
<Skeleton active paragraph={{ rows: 10 }} />
) : (
<>
{/* Cards de resumo */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{filtros.map((f) => (
<Col key={f.key} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
<Card
hoverable
onClick={() => setFiltro(f.key)}
styles={{ body: { padding: '12px 16px' } }}
style={{
borderRadius: 8,
border: filtro === f.key ? `2px solid ${f.cor}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
>
<Statistic
title={f.label}
value={f.count}
styles={{ content: { color: f.cor, fontSize: 24 } }}
/>
</Card>
</Col>
))}
{data && (
<Col xs={12} sm={8} md={6}>
<Card styles={{ body: { padding: '12px 16px' } }} style={{ borderRadius: 8 }}>
<Statistic
title="Faturamento Total (ERP)"
value={fmt(data.faturamentoRepTotal)}
styles={{ content: { color: '#003B8E', fontSize: 18 } }}
/>
</Card>
</Col>
)}
</Row>
{/* Insights */}
{insights.length > 0 && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{insights.map((ins, i) => (
<Col key={i} xs={24} md={12}>
<InsightCard {...ins} />
</Col>
))}
</Row>
)}
{/* Tabela */}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
size="middle"
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: 900 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
onRow={(c) => ({
onClick: () =>
navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } }),
style: { cursor: 'pointer' },
})}
/>
</Card>
</>
)}
</div>
);
}

View File

@@ -161,7 +161,10 @@ export function CatalogPage() {
}} }}
options={pautas?.map((p) => ({ options={pautas?.map((p) => ({
value: p.idPauta, 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> </Space>

View File

@@ -35,16 +35,12 @@ import {
UserOutlined, UserOutlined,
WhatsAppOutlined, WhatsAppOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Doughnut } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary } from '@sar/api-interface'; import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useClientList, useClientDetail } from '../../lib/queries/clients'; import { useClientList, useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders'; import { useClientOrders } from '../../lib/queries/orders';
ChartJS.register(ArcElement, ChartTooltip, Legend);
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -184,150 +180,6 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
); );
} }
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
function CustomerPortfolioCard({
stats,
onDetalhar,
}: {
stats: PortfolioStats;
onDetalhar: () => void;
}) {
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const total = stats.ativos + stats.emAlerta + stats.inativos;
const donutData = {
labels: ['Ativos', 'Em alerta', 'Inativos'],
datasets: [
{
data: [stats.ativos, stats.emAlerta, stats.inativos],
backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'],
borderColor: '#fff',
borderWidth: 3,
hoverOffset: 6,
},
],
};
const donutOptions = {
responsive: true,
maintainAspectRatio: true,
cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: { label: string; raw: unknown }) => {
const v = ctx.raw as number;
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0';
return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`;
},
},
},
},
};
const legendItems = [
{ label: 'Ativos', value: stats.ativos, color: '#52C41A' },
{ label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' },
{ label: 'Inativos', value: stats.inativos, color: '#FF4D4F' },
];
return (
<Card
style={{
borderRadius: 12,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 6px rgba(0,0,0,0.07)',
}}
styles={{ body: { padding: '20px 20px 16px' } }}
>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ fontSize: 14, color: '#1F2937', display: 'block' }}>
Carteira de Clientes
</Text>
<Text style={{ fontSize: 12, color: '#64748B', textTransform: 'capitalize' }}>
{mesAtual}
</Text>
</div>
<div style={{ position: 'relative', width: 180, margin: '0 auto 16px' }}>
{stats.loaded && total > 0 ? (
<>
<Doughnut data={donutData} options={donutOptions} />
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
textAlign: 'center',
pointerEvents: 'none',
}}
>
<Text
strong
style={{ fontSize: 22, color: '#1F2937', display: 'block', lineHeight: 1.1 }}
>
{stats.total.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#64748B' }}>Clientes</Text>
</div>
</>
) : (
<div
style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Spin />
</div>
)}
</div>
<Space orientation="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
{legendItems.map((item) => {
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
return (
<div
key={item.label}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Space size={6}>
<div
style={{
width: 10,
height: 10,
borderRadius: 2,
background: item.color,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 12, color: '#475569' }}>{item.label}</Text>
</Space>
<Space size={8}>
<Text strong style={{ fontSize: 12 }}>
{item.value.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#94A3B8', width: 44, textAlign: 'right' }}>
{pct}%
</Text>
</Space>
</div>
);
})}
</Space>
<Divider style={{ margin: '12px 0' }} />
<Button
block
onClick={onDetalhar}
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
>
Detalhar carteira
</Button>
</Card>
);
}
// ─── CustomerExpandedDetail ─────────────────────────────────────────────────── // ─── CustomerExpandedDetail ───────────────────────────────────────────────────
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) { function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
@@ -1460,22 +1312,9 @@ export function ClientsPage() {
</Row> </Row>
</Card> </Card>
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
{isMobile && (
<>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
<div style={{ marginBottom: 16 }} />
</>
)}
{/* ── Área principal ────────────────────────────────────────────── */} {/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}> <Row gutter={[20, 20]}>
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com <Col xs={24} style={{ minWidth: 0 }}>
scroll x estoura a coluna e engole o card vizinho. */}
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}> <div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" /> <Spin size="large" />
@@ -1548,15 +1387,6 @@ export function ClientsPage() {
</Card> </Card>
)} )}
</Col> </Col>
{!isMobile && (
<Col lg={7}>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
</Col>
)}
</Row> </Row>
{/* ── Modais ────────────────────────────────────────────────────── */} {/* ── Modais ────────────────────────────────────────────────────── */}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Button, Card, Divider, Flex, Space, Tag, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown, faTags } from '@fortawesome/free-solid-svg-icons';
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } from '../../lib/queries/politicas';
const { Text } = Typography;
function fmtData(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('pt-BR');
}
function alvoPromocao(p: PromocaoVigente): string {
if (p.codProduto) return p.nomeProduto ?? `Produto ${p.codProduto}`;
if (p.grpProd) return `Grupo ${p.grupo ?? p.grpProd}`;
return 'Todos os produtos';
}
function alvoRegra(r: RegraVigente): string {
if (r.codGrupo != null) return `Grupo ${r.grupo ?? r.codGrupo}`;
if (r.codSubgrupo != null) return `Subgrupo ${r.subgrupo ?? r.codSubgrupo}`;
return 'Todos os produtos';
}
function LinhaCondicao({
tipo,
descricao,
alvo,
descPct,
dataFim,
}: {
tipo: 'promocao' | 'regra';
descricao: string;
alvo: string;
descPct: number;
dataFim: string;
}) {
return (
<Flex justify="space-between" align="center" gap={12} wrap>
<Space orientation="vertical" size={0}>
<Space size={8}>
<Tag
color={tipo === 'promocao' ? 'orange' : 'blue'}
style={{ borderRadius: 20, fontSize: 10, fontWeight: 700, marginInlineEnd: 0 }}
>
{tipo === 'promocao' ? 'PROMOÇÃO' : 'DESCONTO LIBERADO'}
</Tag>
<Text strong>{descricao}</Text>
</Space>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{alvo}
</Text>
</Space>
<Space size={8}>
<Tag color="green" className="tabular-nums" style={{ borderRadius: 20, fontWeight: 700 }}>
{descPct}% off
</Tag>
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
até {fmtData(dataFim)}
</Text>
</Space>
</Flex>
);
}
// Card expansível do painel do rep: promoções e regras de desconto vigentes
// HOJE para o representante logado. Some sozinho quando a condição encerra ou
// é inativada pelo gerente (refetch a cada minuto nos hooks de vigentes).
export function CondicoesEspeciaisCard() {
const { data: promosData } = usePromocoesVigentes();
const { data: regrasData } = useRegrasVigentes();
const [expandido, setExpandido] = useState(false);
const promocoes = promosData?.promocoes ?? [];
const regras = regrasData?.regras ?? [];
const total = promocoes.length + regras.length;
if (total === 0) return null;
return (
<Card
styles={{ body: expandido ? {} : { display: 'none' } }}
title={
<Flex
justify="space-between"
align="center"
style={{ cursor: 'pointer' }}
onClick={() => setExpandido((v) => !v)}
>
<Space>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--orange)' }} />
Condições Especiais
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
{total}
</Tag>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', fontWeight: 400 }}>
vigentes hoje
</Text>
</Space>
<Button
type="text"
size="small"
aria-label={expandido ? 'Recolher' : 'Expandir'}
icon={
<FontAwesomeIcon
icon={faChevronDown}
style={{
transition: 'transform 0.2s',
transform: expandido ? 'rotate(180deg)' : undefined,
}}
/>
}
/>
</Flex>
}
>
<Flex vertical gap={12}>
{promocoes.map((p: PromocaoVigente, i: number) => (
<div key={`p-${p.id}`}>
{i > 0 && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="promocao"
descricao={p.descricao}
alvo={alvoPromocao(p)}
descPct={p.descPct}
dataFim={p.dataFim}
/>
</div>
))}
{regras.map((r: RegraVigente, i: number) => (
<div key={`r-${r.id}`}>
{(i > 0 || promocoes.length > 0) && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="regra"
descricao={r.descricao}
alvo={alvoRegra(r)}
descPct={r.descPct}
dataFim={r.dataFim}
/>
</div>
))}
</Flex>
</Card>
);
}

View File

@@ -29,9 +29,12 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
CloseOutlined, CloseOutlined,
DeleteOutlined, DeleteOutlined,
DownOutlined,
HistoryOutlined,
PlusOutlined, PlusOutlined,
SearchOutlined, SearchOutlined,
ShoppingCartOutlined, ShoppingCartOutlined,
UpOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useSearch } from '@tanstack/react-router'; import { useNavigate, useSearch } from '@tanstack/react-router';
@@ -41,16 +44,16 @@ import type {
FormaPagamento, FormaPagamento,
Pauta, Pauta,
ProdutoSummary, ProdutoSummary,
RegraVigente,
TopProduto, TopProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients'; import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
import { useOrderDetail } from '../../lib/queries/orders';
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog'; import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
import { useRegrasVigentes } from '../../lib/queries/politicas'; import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
import { useCondicoesComerciais } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag'; import { CondicaoTag } from '../../components/CondicaoTag';
import { apiFetch } from '../../lib/api-client'; import { apiFetch } from '../../lib/api-client';
import { enqueueOrder } from '../../lib/offline/order-queue'; import { enqueueOrder } from '../../lib/offline/order-queue';
import { randomUUID } from '../../lib/uuid';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -67,9 +70,12 @@ type CartItem = {
precoUnitario: number; precoUnitario: number;
descontoPerc: number; descontoPerc: number;
loteMulVenda: number | null; 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 ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -630,17 +636,20 @@ function CatalogBrowserModal({
function MobileCartItem({ function MobileCartItem({
item, item,
teto,
onQtyChange, onQtyChange,
onDiscChange, onDiscChange,
onRemove, onRemove,
}: { }: {
item: CartItem; item: CartItem;
teto: number;
onQtyChange: (key: string, qty: number) => void; onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void; onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void; onRemove: (key: string) => void;
}) { }) {
const lote = item.loteMulVenda ?? 1; const lote = item.loteMulVenda ?? 1;
const invalid = lote > 1 && item.qtd % lote !== 0; const invalid = lote > 1 && item.qtd % lote !== 0;
const acimaTeto = item.descontoPerc > teto + 0.001;
return ( return (
<div <div
@@ -712,16 +721,29 @@ function MobileCartItem({
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}> <Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
Desc% Desc%
</Text> </Text>
<InputNumber <Tooltip
min={0} title={
max={100} acimaTeto
step={0.5} ? `Acima do teto de ${teto}% — a transmissão será bloqueada`
value={item.descontoPerc} : `Teto do item: ${teto}%`
size="small" }
style={{ width: 88 }} color={acimaTeto ? 'red' : 'blue'}
addonAfter="%" >
onChange={(n) => onDiscChange(item.key, n ?? 0)} <InputNumber
/> min={0}
max={100}
step={0.5}
value={item.descontoPerc}
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>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}> <div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}> <Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
@@ -749,7 +771,8 @@ function ClientHistoryPanel({
}) { }) {
const screens = useBreakpoint(); const screens = useBreakpoint();
const isMobile = !screens.md; 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 [qtys, setQtys] = useState<Record<number, number>>({});
const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15); const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15);
@@ -910,40 +933,61 @@ function ClientHistoryPanel({
return ( return (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
{/* Banner âmbar de destaque — fechado por padrão, convida o rep a abrir */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', 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', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
}} }}
onClick={() => setCollapsed((c) => !c)} onClick={() => setCollapsed((c) => !c)}
> >
<Text <div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<HistoryOutlined style={{ color: '#D46B08', fontSize: 18, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<Text
strong
style={{ color: '#873800', fontSize: 13, display: 'block', lineHeight: 1.3 }}
>
Este cliente comprou {produtos.length} produto{produtos.length !== 1 ? 's' : ''}{' '}
com você
</Text>
<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={{ style={{
fontSize: 11, borderColor: '#FFD591',
fontWeight: 700, color: '#D46B08',
letterSpacing: '0.08em', background: '#fff',
textTransform: 'uppercase', fontWeight: 600,
color: '#64748B', flexShrink: 0,
}} }}
icon={collapsed ? <DownOutlined /> : <UpOutlined />}
iconPosition="end"
> >
Comprados anteriormente · {produtos.length} produto{produtos.length !== 1 ? 's' : ''} {collapsed ? 'Ver produtos' : 'Ocultar'}
</Text>
<Button type="link" size="small" style={{ padding: 0, fontSize: 12 }}>
{collapsed ? 'exibir' : 'ocultar'}
</Button> </Button>
</div> </div>
{!collapsed && ( {!collapsed && (
<div <div
style={{ style={{
border: '1px solid #EBF0F5', border: '1px solid #FFD591',
borderRadius: 8, borderTop: 'none',
borderRadius: '0 0 8px 8px',
overflow: 'hidden', overflow: 'hidden',
background: '#FAFBFC', background: '#FFFDF7',
}} }}
> >
<Table<TopProduto> <Table<TopProduto>
@@ -970,11 +1014,13 @@ function ClientHistoryPanel({
function OrderItemsTable({ function OrderItemsTable({
items, items,
tetoDe,
onQtyChange, onQtyChange,
onDiscChange, onDiscChange,
onRemove, onRemove,
}: { }: {
items: CartItem[]; items: CartItem[];
tetoDe: (item: CartItem) => number;
onQtyChange: (key: string, qty: number) => void; onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void; onDiscChange: (key: string, disc: number) => void;
onRemove: (key: string) => void; onRemove: (key: string) => void;
@@ -1015,6 +1061,7 @@ function OrderItemsTable({
<MobileCartItem <MobileCartItem
key={item.key} key={item.key}
item={item} item={item}
teto={tetoDe(item)}
onQtyChange={onQtyChange} onQtyChange={onQtyChange}
onDiscChange={onDiscChange} onDiscChange={onDiscChange}
onRemove={onRemove} onRemove={onRemove}
@@ -1091,19 +1138,38 @@ function OrderItemsTable({
{ {
title: 'Desc %', title: 'Desc %',
dataIndex: 'descontoPerc', dataIndex: 'descontoPerc',
width: 100, width: 110,
render: (v: number, row: CartItem) => ( render: (v: number, row: CartItem) => {
<InputNumber const teto = tetoDe(row);
min={0} const acimaTeto = v > teto + 0.001;
max={100} return (
step={0.5} <div>
value={v} <Tooltip
size="small" title={
style={{ width: 76 }} acimaTeto
addonAfter="%" ? `Acima do teto de ${teto}% — a transmissão será bloqueada`
onChange={(n) => onDiscChange(row.key, n ?? 0)} : `Teto do item: ${teto}%`
/> }
), color={acimaTeto ? 'red' : 'blue'}
>
<InputNumber
min={0}
max={100}
step={0.5}
value={v}
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', title: 'Total',
@@ -1251,11 +1317,16 @@ function OrderSummaryFooter({
// ─── NewOrderPage ───────────────────────────────────────────────────────────── // ─── NewOrderPage ─────────────────────────────────────────────────────────────
export function 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 navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const { message } = App.useApp(); const { message } = App.useApp();
// ── Modo edição: orçamento (situa 0) carregado para alteração ──
const { data: editOrder } = useOrderDetail(editarId);
// ── Dados do cliente ── // ── Dados do cliente ──
const [clientSearch, setClientSearch] = useState(''); const [clientSearch, setClientSearch] = useState('');
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null); const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
@@ -1267,8 +1338,11 @@ export function NewOrderPage() {
const { data: selectedClientDetail } = useClientDetail( const { data: selectedClientDetail } = useClientDetail(
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined, !clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
); );
const clientDetail = selectedClientDetail ?? preloadedClient ?? null; const { data: editClient } = useClientDetail(
const effectiveClient = selectedClient ?? preloadedClient ?? null; editarId && editOrder ? editOrder.idCliente : undefined,
);
const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null;
const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null;
// ── Campos comerciais ── // ── Campos comerciais ──
const { data: pautas = [] } = usePautas(); const { data: pautas = [] } = usePautas();
@@ -1293,6 +1367,39 @@ export function NewOrderPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [catalogOpen, setCatalogOpen] = useState(false); 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. // Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
const clientEndStr = (() => { const clientEndStr = (() => {
if (!clientDetail) return ''; if (!clientDetail) return '';
@@ -1306,43 +1413,40 @@ export function NewOrderPage() {
})(); })();
const hasClientEnd = !!clientEndStr; 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 totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
const loteErrors = cart.filter((it) => { const loteErrors = cart.filter((it) => {
const lote = it.loteMulVenda ?? 1; const lote = it.loteMulVenda ?? 1;
return lote > 1 && it.qtd % lote !== 0; 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 const missingReason = !effectiveClient
? 'Selecione um cliente para continuar' ? 'Selecione um cliente para continuar'
: cart.length === 0 : cart.length === 0
? 'Adicione ao menos um produto ao pedido' ? 'Adicione ao menos um produto ao pedido'
: loteErrors.length > 0 : loteErrors.length > 0
? 'Corrija as quantidades com lote múltiplo' ? 'Corrija as quantidades com lote múltiplo'
: undefined; : tetoErrors.length > 0
? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}`
: undefined;
function clearClient() { function clearClient() {
setSelectedClient(null); setSelectedClient(null);
setClientSearch(''); 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 ── // ── 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 addToCart = (p: ProdutoSummary, qty?: number) => {
const lote = p.loteMulVenda ?? 1; const lote = p.loteMulVenda ?? 1;
const finalQty = qty ?? lote; const finalQty = qty ?? lote;
@@ -1363,8 +1467,10 @@ export function NewOrderPage() {
unidade: p.unidade ?? '', unidade: p.unidade ?? '',
qtd: finalQty, qtd: finalQty,
precoUnitario: Number(p.vlPreco1), precoUnitario: Number(p.vlPreco1),
descontoPerc: regraDescontoPct(p.codGrupo, p.codSubgrupo), descontoPerc: 0,
loteMulVenda: p.loteMulVenda, loteMulVenda: p.loteMulVenda,
codGrupo: p.codGrupo,
codSubgrupo: p.codSubgrupo,
}, },
]; ];
}); });
@@ -1386,9 +1492,11 @@ export function NewOrderPage() {
unidade: p.unidade ?? '', unidade: p.unidade ?? '',
qtd: qty, qtd: qty,
precoUnitario: p.ultimoPreco, precoUnitario: p.ultimoPreco,
// TopProduto não traz grupo/subgrupo — aplica só regra sem restrição descontoPerc: 0,
descontoPerc: regraDescontoPct(null, null),
loteMulVenda: null, 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(', ')}`, `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 = [ const obsCompleta = [
contato ? `Contato: ${contato}` : null, contato ? `Contato: ${contato}` : null,
@@ -1435,7 +1550,7 @@ export function NewOrderPage() {
descontoPerc: 0, descontoPerc: 0,
obs: obsCompleta || undefined, obs: obsCompleta || undefined,
endEntrega: endEntregaValue, endEntrega: endEntregaValue,
idempotencyKey: crypto.randomUUID(), idempotencyKey: editarId ? undefined : randomUUID(),
itens: cart.map((it, idx) => ({ itens: cart.map((it, idx) => ({
idProduto: it.idProduto, idProduto: it.idProduto,
codProduto: it.codProduto, 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) { if (!navigator.onLine) {
await enqueueOrder(body, effectiveClient.nome); await enqueueOrder(body, effectiveClient.nome);
window.dispatchEvent(new CustomEvent('sar:order-queued')); window.dispatchEvent(new CustomEvent('sar:order-queued'));
@@ -1458,6 +1583,11 @@ export function NewOrderPage() {
onSuccess: (created) => { onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['orders'] }); void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients'] }); 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) { if (!created) {
setError(null); setError(null);
setCart([]); setCart([]);
@@ -1475,7 +1605,7 @@ export function NewOrderPage() {
} }
void navigate({ to: '/pedidos/$id', params: { id: created.id } }); 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 ( return (
@@ -1490,7 +1620,9 @@ export function NewOrderPage() {
}} }}
> >
<Title level={3} style={{ margin: 0, color: '#003B8E' }}> <Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Lançamento de Pedido {editarId
? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}`
: 'Lançamento de Pedido'}
</Title> </Title>
<Button <Button
icon={<ArrowLeftOutlined />} icon={<ArrowLeftOutlined />}
@@ -1700,6 +1832,7 @@ export function NewOrderPage() {
<OrderItemsTable <OrderItemsTable
items={cart} items={cart}
tetoDe={tetoDe}
onQtyChange={setQty} onQtyChange={setQty}
onDiscChange={setDisc} onDiscChange={setDisc}
onRemove={removeItem} onRemove={removeItem}

View File

@@ -6,16 +6,12 @@ import {
Button, Button,
Descriptions, Descriptions,
Divider, Divider,
Form,
InputNumber,
Modal,
Space, Space,
Spin, Spin,
Table, Table,
Tag, Tag,
Timeline, Timeline,
Typography, Typography,
Input,
message, message,
} from 'antd'; } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -32,7 +28,6 @@ import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store'; import { authStore } from '../../lib/auth-store';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -144,105 +139,6 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
); );
} }
// ─── Approve Modal ────────────────────────────────────────────────────────────
function ApproveModal({
open,
originalDiscount,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
originalDiscount: string;
onConfirm: (descontoPerc?: number, nota?: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [disc, setDisc] = useState<number | null>(null);
const [nota, setNota] = useState('');
return (
<Modal
title="Aprovar Pedido"
open={open}
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
onCancel={onCancel}
okText="Confirmar Aprovação"
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item
label={`Desconto global (original: ${originalDiscount}%)`}
help="Deixe em branco para manter o desconto solicitado."
>
<InputNumber
min={0}
max={100}
step={0.5}
placeholder={originalDiscount}
value={disc}
onChange={(v) => setDisc(v)}
addonAfter="%"
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item label="Observação (opcional)">
<TextArea
rows={2}
value={nota}
onChange={(e) => setNota(e.target.value)}
maxLength={300}
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── Reject Modal ─────────────────────────────────────────────────────────────
function RejectModal({
open,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
onConfirm: (motivo: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [motivo, setMotivo] = useState('');
return (
<Modal
title="Recusar Pedido"
open={open}
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
onCancel={onCancel}
okText="Confirmar Recusa"
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item label="Motivo da recusa" required>
<TextArea
rows={3}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
maxLength={500}
showCount
placeholder="Informe o motivo para o representante..."
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── OrderDetailPage ────────────────────────────────────────────────────────── // ─── OrderDetailPage ──────────────────────────────────────────────────────────
export function OrderDetailPage() { export function OrderDetailPage() {
@@ -254,7 +150,6 @@ export function OrderDetailPage() {
const role = getRoleFromToken(); const role = getRoleFromToken();
const isErp = order?.fonte === 'erp'; const isErp = order?.fonte === 'erp';
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
const canTransmit = !isErp && role === 'rep' && order?.situa === 0; const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
const canShare = const canShare =
role === 'rep' && role === 'rep' &&
@@ -262,39 +157,9 @@ export function OrderDetailPage() {
typeof navigator !== 'undefined' && typeof navigator !== 'undefined' &&
!!navigator.share; !!navigator.share;
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [chamadoOpen, setChamadoOpen] = useState(false); const [chamadoOpen, setChamadoOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
onSuccess: () => {
setApproveOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setApproveOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
},
});
const rejectMutation = useMutation({
mutationFn: (motivo: string) =>
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
onSuccess: () => {
setRejectOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setRejectOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
},
});
const transmitMutation = useMutation({ const transmitMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }), mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
onSuccess: () => { onSuccess: () => {
@@ -309,11 +174,6 @@ export function OrderDetailPage() {
if (error || !order) if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />; return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const timeWaiting =
order.situa === 1
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
: null;
// Orçamento: tela mais larga para consulta/revisão com o cliente. // Orçamento: tela mais larga para consulta/revisão com o cliente.
const isOrcamento = order.situa === 0; const isOrcamento = order.situa === 0;
@@ -339,9 +199,6 @@ export function OrderDetailPage() {
</Tag> </Tag>
} }
/> />
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red">Urgente {timeWaiting}h aguardando</Tag>
)}
<Button <Button
icon={<FilePdfOutlined />} icon={<FilePdfOutlined />}
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })} onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
@@ -361,16 +218,6 @@ export function OrderDetailPage() {
Transmitir pedido Transmitir pedido
</Button> </Button>
)} )}
{canAct && (
<Space>
<Button type="primary" onClick={() => setApproveOpen(true)}>
Aprovar
</Button>
<Button danger onClick={() => setRejectOpen(true)}>
Recusar
</Button>
</Space>
)}
{canShare && ( {canShare && (
<Button <Button
icon={<FontAwesomeIcon icon={faShareNodes} />} icon={<FontAwesomeIcon icon={faShareNodes} />}
@@ -420,11 +267,6 @@ export function OrderDetailPage() {
<Descriptions.Item label="Data"> <Descriptions.Item label="Data">
{new Date(order.dtPedido).toLocaleDateString('pt-BR')} {new Date(order.dtPedido).toLocaleDateString('pt-BR')}
</Descriptions.Item> </Descriptions.Item>
{order.aprovadoEm && (
<Descriptions.Item label="Aprovado em">
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} cód. {order.aprovadoPor}
</Descriptions.Item>
)}
{order.formaPagamento && ( {order.formaPagamento && (
<Descriptions.Item label="Cond. Pagamento" span={2}> <Descriptions.Item label="Cond. Pagamento" span={2}>
{order.formaPagamento} {order.formaPagamento}
@@ -511,19 +353,6 @@ export function OrderDetailPage() {
<Divider titlePlacement="left">Histórico do Pedido</Divider> <Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} /> <HistoryTimeline history={order.historico} />
<ApproveModal
open={approveOpen}
originalDiscount={order.descontoPerc}
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
onCancel={() => setApproveOpen(false)}
loading={approveMutation.isPending}
/>
<RejectModal
open={rejectOpen}
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>
{order && ( {order && (
<AbrirChamadoModal <AbrirChamadoModal
open={chamadoOpen} open={chamadoOpen}

View File

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

View File

@@ -6,6 +6,8 @@ import {
Card, Card,
Col, Col,
DatePicker, DatePicker,
Form,
Input,
Modal, Modal,
Dropdown, Dropdown,
Grid, Grid,
@@ -28,6 +30,7 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
CopyOutlined, CopyOutlined,
DollarOutlined, DollarOutlined,
EditOutlined,
EllipsisOutlined, EllipsisOutlined,
EyeOutlined, EyeOutlined,
FilePdfOutlined, FilePdfOutlined,
@@ -139,13 +142,11 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
function useOrderStats() { function useOrderStats() {
const all = useOrderList({ limit: 1 }); const all = useOrderList({ limit: 1 });
const pendentes = useOrderList({ limit: 1, situa: 1 }); const transmitidos = useOrderList({ limit: 1, situa: 2 });
const aprovados = useOrderList({ limit: 1, situa: 2 });
const faturados = useOrderList({ limit: 1, situa: 4 }); const faturados = useOrderList({ limit: 1, situa: 4 });
return { return {
total: all.data?.total ?? 0, total: all.data?.total ?? 0,
pendentes: pendentes.data?.total ?? 0, transmitidos: transmitidos.data?.total ?? 0,
aprovados: aprovados.data?.total ?? 0,
faturados: faturados.data?.total ?? 0, faturados: faturados.data?.total ?? 0,
loaded: !!all.data, loaded: !!all.data,
}; };
@@ -164,19 +165,18 @@ function OrdersMetrics({ stats }: { stats: OrderStats }) {
color: '#003B8E', color: '#003B8E',
}, },
{ {
label: 'Ag. Aprovação', label: 'Transmitidos',
value: stats.pendentes, value: stats.transmitidos,
icon: <ClockCircleOutlined />, icon: <CheckCircleOutlined />,
color: '#d46b08', color: '#389e0d',
}, },
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' }, { label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
]; ];
return ( return (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}> <Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{metrics.map((m) => ( {metrics.map((m) => (
<Col key={m.label} xs={12} sm={6}> <Col key={m.label} xs={12} sm={8}>
<Card <Card
style={{ style={{
borderRadius: 10, borderRadius: 10,
@@ -244,6 +244,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
// ─── OrderActionsMenu ───────────────────────────────────────────────────────── // ─── OrderActionsMenu ─────────────────────────────────────────────────────────
const MOTIVOS_CANCELAMENTO = [
'Cliente desistiu',
'Preço',
'Prazo de entrega',
'Erro de lançamento',
'Outro',
];
function OrderActionsMenu({ function OrderActionsMenu({
order, order,
onView, onView,
@@ -253,18 +261,24 @@ function OrderActionsMenu({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); 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({ 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: () => { onSuccess: () => {
void msg.success('Orçamento cancelado.'); void msg.success('Orçamento cancelado.');
setCancelOpen(false);
void qc.invalidateQueries({ queryKey: ['orders'] }); void qc.invalidateQueries({ queryKey: ['orders'] });
}, },
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'), 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 canCancel = order.situa === 0 && order.fonte !== 'erp';
const canEdit = canCancel;
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
{ {
@@ -273,6 +287,16 @@ function OrderActionsMenu({
label: 'Ver detalhes', label: 'Ver detalhes',
onClick: () => onView(order.id), 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', key: 'duplicate',
icon: <CopyOutlined style={{ color: '#0057D9' }} />, icon: <CopyOutlined style={{ color: '#0057D9' }} />,
@@ -305,22 +329,59 @@ function OrderActionsMenu({
disabled: !canCancel, disabled: !canCancel,
onClick: () => { onClick: () => {
if (!canCancel) return; if (!canCancel) return;
void modal.confirm({ cancelForm.resetFields();
title: 'Cancelar orçamento?', setCancelOpen(true);
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(),
});
}, },
}, },
]; ];
return ( return (
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight"> // stopPropagation: o clique nos "..." não pode acionar o clique da LINHA
<Button type="text" icon={<EllipsisOutlined />} size="small" /> // (que abre o detalhe) — o menu abre sozinho e só a opção escolhida age.
</Dropdown> <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>
); );
} }
@@ -801,7 +862,7 @@ export function OrdersPage() {
Pedidos Pedidos
</Title> </Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}> <p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Acompanhe seus pedidos, status de aprovação e histórico comercial. Acompanhe seus pedidos, status de transmissão e histórico comercial.
</p> </p>
</div> </div>
{!isMobile && ( {!isMobile && (
@@ -885,7 +946,6 @@ export function OrdersPage() {
}} }}
options={[ options={[
{ value: 0, label: 'Orçamento' }, { value: 0, label: 'Orçamento' },
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Transmitido' }, { value: 2, label: 'Transmitido' },
{ value: 3, label: 'Cancelado' }, { value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' }, { value: 4, label: 'Faturado' },

View File

@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Alert, Alert,
Badge,
Card, Card,
Col, Col,
DatePicker, DatePicker,
@@ -17,10 +16,10 @@ import {
Typography, Typography,
} from 'antd'; } from 'antd';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons'; import { AlertOutlined, BarChartOutlined, RiseOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface'; import type { AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports'; import { useReportMeta, useReportAbc } from '../../lib/queries/reports';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
@@ -29,11 +28,6 @@ function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
// ─── Aba 1: Desempenho vs. Meta ─────────────────────────────────────────────── // ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
function TabMeta() { function TabMeta() {
@@ -196,177 +190,7 @@ function TabMeta() {
); );
} }
// ─── Aba 2: Carteira de Clientes ───────────────────────────────────────────── // ─── Aba 2: Curva ABC ─────────────────────────────────────────────────────────
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading, isError, error } = useReportCarteira();
const clientes = data?.data ?? [];
const filtered = clientes.filter((c: CarteiraCliente) => {
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
if (filtro === 'semPedido') return c.totalPedidos === 0;
return true;
});
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
return (
<Badge
color="#ef4444"
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
/>
);
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
return (
<Badge
color="#f59e0b"
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space orientation="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
{c.razao && c.nome && (
<Text type="secondary" style={{ fontSize: 11 }}>
{c.nome}
</Text>
)}
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 140,
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
},
{
title: 'Último Pedido',
dataIndex: 'ultimoPedido',
width: 130,
render: (v: string | null) => (
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
),
},
{
title: 'Dias Sem Pedido',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 90,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
},
];
if (isError) {
return (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira de clientes"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
);
}
return (
<div>
{data && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{[
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
].map((item) => (
<Col key={item.key} xs={12} md={6}>
<Card
hoverable
onClick={() => setFiltro(item.key as FiltroCarteira)}
style={{
borderRadius: 8,
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
styles={{ body: { padding: '12px 16px' } }}
>
<Statistic
title={item.label}
value={item.value}
styles={{ content: { color: item.color, fontSize: 24 } }}
/>
</Card>
</Col>
))}
</Row>
)}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
loading={isLoading}
size="middle"
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 700 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
</div>
);
}
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' }; const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
@@ -550,16 +374,6 @@ export function RelatoriosPage() {
), ),
children: <TabMeta />, children: <TabMeta />,
}, },
{
key: 'carteira',
label: (
<Space>
<TeamOutlined />
Carteira de Clientes
</Space>
),
children: <TabCarteira />,
},
{ {
key: 'abc', key: 'abc',
label: ( label: (
@@ -579,7 +393,7 @@ export function RelatoriosPage() {
Relatórios Relatórios
</Title> </Title>
<Text type="secondary" style={{ fontSize: 14 }}> <Text type="secondary" style={{ fontSize: 14 }}>
Análise de desempenho, carteira e mix de produtos. Análise de desempenho e mix de produtos.
</Text> </Text>
</div> </div>

View File

@@ -39,6 +39,7 @@ import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard'; import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
import { CondicoesEspeciaisCard } from './CondicoesEspeciaisCard';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -553,6 +554,9 @@ export function RepPainel() {
</Col> </Col>
</Row> </Row>
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
<CondicoesEspeciaisCard />
{/* Metas por Grupo */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
<Card <Card

View File

@@ -1,108 +0,0 @@
import { Table, Tag, Typography, Badge, Space } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { useOrderList } from '../../lib/queries/orders';
const { Title } = Typography;
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Representante',
key: 'rep',
width: 160,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Cliente',
key: 'cliente',
width: 200,
render: (_: unknown, row: PedidoSummary) =>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Desc. %',
dataIndex: 'descontoPerc',
width: 90,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Aguardando',
dataIndex: 'createdAt',
width: 130,
render: (v: string) => {
const h = hoursWaiting(v);
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
},
},
{
title: '',
width: 100,
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
</Tag>
</Link>
),
},
];
export function ApprovalQueuePage() {
// situa=1 = Pendente de Aprovação
const { data, isLoading } = useOrderList({ situa: 1, limit: 200 });
const urgentCount = data?.data.filter((o) => hoursWaiting(o.createdAt) > 2).length ?? 0;
return (
<div style={{ padding: 24 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>
Fila de Aprovações
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h aguardando`}
/>
)}
</Space>
<Table<PedidoSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
pagination={false}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</div>
);
}

View File

@@ -1,15 +1,41 @@
import { Badge, Card, Col, Flex, Row, Skeleton, Space, Table, Tag, Typography } from 'antd'; import { useState } from 'react';
import {
Button,
Card,
Col,
Flex,
Modal,
Progress,
Row,
Skeleton,
Space,
Spin,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { WhatsAppOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faCheckCircle, faAddressCard,
faCircleExclamation, faCircleExclamation,
faClipboardList, faClipboardList,
faRankingStar,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface'; import type {
import { useSupervisorDashboard } from '../../lib/queries/dashboard'; ClienteNaoPositivado,
PositivacaoRep,
RankingRep,
RepInativosSummary,
} from '@sar/api-interface';
import { useRepInativos, useSupervisorDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -17,10 +43,6 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
function delta(current: number, previous: number): { label: string; positive: boolean } | null { function delta(current: number, previous: number): { label: string; positive: boolean } | null {
if (previous === 0) return null; if (previous === 0) return null;
const pct = Math.round(((current - previous) / previous) * 100); const pct = Math.round(((current - previous) / previous) * 100);
@@ -38,62 +60,116 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
} }
const queueColumns: TableColumnsType<PedidoSummary> = [ function mesLabel(): string {
{ return new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
title: 'Pedido', }
dataIndex: 'numPedSar',
width: 120, function waLink(whatsapp: string): string {
render: (num: string, row: PedidoSummary) => ( const digits = whatsapp.replace(/\D/g, '');
<Link to="/pedidos/$id" params={{ id: row.id }}> const number = digits.startsWith('55') ? digits : `55${digits}`;
{num} return `https://wa.me/${number}`;
</Link> }
),
}, // ─── Modal de clientes inativos de um rep ─────────────────────────────────────
{
title: 'Representante', const inativosColumns: TableColumnsType<ClienteNaoPositivado> = [
key: 'rep',
width: 150,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{ {
title: 'Cliente', title: 'Cliente',
key: 'cliente', key: 'cliente',
width: 180, render: (_: unknown, c: ClienteNaoPositivado) => (
render: (_: unknown, row: PedidoSummary) => <Space orientation="vertical" size={0}>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`, <Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
{c.razao ?? c.nome}
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {c.idCliente}
</Text>
</Space>
),
}, },
{ {
title: 'Total', title: 'Última compra',
dataIndex: 'total', dataIndex: 'ultimoPedido',
width: 130, width: 140,
align: 'right', render: (v: string | null) =>
render: (v: string) => fmt(Number(v)), v ? new Date(`${v}T00:00:00`).toLocaleDateString('pt-BR') : <Tag>Nunca comprou</Tag>,
}, },
{ {
title: 'Aguardando', title: 'Sem compra',
dataIndex: 'createdAt', dataIndex: 'diasSemPedido',
width: 120, width: 120,
render: (v: string) => { render: (d: number, c: ClienteNaoPositivado) =>
const h = hoursWaiting(v); c.comprouAntes ? (
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>; <Tag color={d > 90 ? 'red' : 'orange'} className="tabular-nums">
}, {d} dias
</Tag>
) : (
'—'
),
}, },
{ {
title: '', title: '',
width: 90, key: 'acoes',
render: (_: unknown, row: PedidoSummary) => ( width: 120,
<Link to="/pedidos/$id" params={{ id: row.id }}> render: (_: unknown, c: ClienteNaoPositivado) => (
<Tag color="blue" style={{ cursor: 'pointer' }}> <Space>
Analisar <Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
</Tag> <Button size="small">Ficha</Button>
</Link> </Link>
{c.whatsapp && (
<Button
size="small"
type="primary"
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
icon={<WhatsAppOutlined />}
href={waLink(c.whatsapp)}
target="_blank"
rel="noopener noreferrer"
/>
)}
</Space>
), ),
}, },
]; ];
function InativosRepModal({
rep,
onClose,
}: {
rep: RepInativosSummary | null;
onClose: () => void;
}) {
const { data, isLoading } = useRepInativos(rep?.codVendedor ?? null);
return (
<Modal
open={rep !== null}
onCancel={onClose}
footer={null}
width={900}
centered
title={rep ? `Clientes Inativos — ${rep.nomeVendedor ?? `Rep cód. ${rep.codVendedor}`}` : ''}
>
{isLoading ? (
<Spin style={{ display: 'block', margin: '32px auto' }} />
) : (
<Table<ClienteNaoPositivado>
rowKey="idCliente"
columns={inativosColumns}
dataSource={data?.clientes ?? []}
size="small"
pagination={{ pageSize: 10, hideOnSinglePage: true }}
locale={{ emptyText: 'Nenhum cliente inativo.' }}
/>
)}
</Modal>
);
}
export function SupervisorPainel() { export function SupervisorPainel() {
const { data, isLoading } = useSupervisorDashboard(); const { data, isLoading } = useSupervisorDashboard();
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
const [inativosRep, setInativosRep] = useState<RepInativosSummary | null>(null);
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -110,8 +186,7 @@ export function SupervisorPainel() {
); );
} }
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data; const { pedidosDia, inativosPorRep, equipeMes, rankingReps, positivacaoReps, syncedAt } = data;
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior); const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior); const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
@@ -124,49 +199,12 @@ export function SupervisorPainel() {
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()} {today()}
{urgentCount > 0 && (
<>
{' '}
·{' '}
<span style={{ color: '#cf1322' }}>
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
{urgentCount > 1 ? 's' : ''}
</span>
</>
)}
</Text> </Text>
</Flex> </Flex>
{/* KPIs */} {/* KPIs */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} md={8}> <Col xs={24} md={12}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
APROVAÇÕES PENDENTES
</Text>
<Flex align="center" gap={8}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{approvalQueue.length}
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h`}
/>
)}
</Flex>
<Link to="/aprovacoes">
<Text style={{ fontSize: 'var(--text-sm)', color: 'var(--jcs-blue)' }}>
Ver fila completa
</Text>
</Link>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card> <Card>
<Space orientation="vertical" size={4}> <Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -189,7 +227,7 @@ export function SupervisorPainel() {
</Card> </Card>
</Col> </Col>
<Col xs={24} md={8}> <Col xs={24} md={12}>
<Card> <Card>
<Space orientation="vertical" size={4}> <Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -213,38 +251,9 @@ export function SupervisorPainel() {
</Col> </Col>
</Row> </Row>
{/* Fila de aprovações + Inativos por rep */} {/* Inativos por rep + Pedidos de hoje */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} lg={16}> <Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCheckCircle} style={{ color: 'var(--jcs-blue)' }} />
Fila de Aprovações
{approvalQueue.length > 0 && (
<Badge
count={approvalQueue.length}
style={{ backgroundColor: 'var(--jcs-blue)' }}
/>
)}
</Space>
}
extra={<Link to="/aprovacoes">Ver todas</Link>}
>
<Table<PedidoSummary>
rowKey="id"
columns={queueColumns}
dataSource={approvalQueue.slice(0, 8)}
pagination={false}
size="small"
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card <Card
title={ title={
<Space> <Space>
@@ -281,20 +290,26 @@ export function SupervisorPainel() {
</Text> </Text>
)} )}
</Space> </Space>
<Tag <Space>
color={r.inativosCount >= 3 ? 'orange' : 'default'} <Tag
className="tabular-nums" color={r.inativosCount >= 3 ? 'orange' : 'default'}
> className="tabular-nums"
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''} >
</Tag> {r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
</Tag>
<Button size="small" onClick={() => setInativosRep(r)}>
Detalhar
</Button>
</Space>
</Flex> </Flex>
))} ))}
</Flex> </Flex>
)} )}
</Card> </Card>
</Col>
<Col xs={24} lg={12}>
<Card <Card
style={{ marginTop: 24 }}
title={ title={
<Space> <Space>
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} /> <FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
@@ -328,6 +343,130 @@ export function SupervisorPainel() {
</Col> </Col>
</Row> </Row>
{/* Métricas da equipe no mês */}
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO NO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(equipeMes.faturamento)}
</Title>
<Text
type="secondary"
style={{ fontSize: 'var(--text-sm)' }}
className="tabular-nums"
>
{equipeMes.pedidos} pedido{equipeMes.pedidos !== 1 ? 's' : ''}
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DA EQUIPE
</Text>
{equipeMes.metaTotal > 0 ? (
<>
<Flex align="baseline" gap={8}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{equipeMes.pctMeta}%
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
de {fmt(equipeMes.metaTotal)}
</Text>
</Flex>
<Progress
percent={Math.min(equipeMes.pctMeta, 100)}
size="small"
strokeColor={
equipeMes.pctMeta >= 100
? 'var(--green)'
: equipeMes.pctMeta >= 70
? 'var(--jcs-blue)'
: '#faad14'
}
showInfo={false}
/>
<Text
type="secondary"
style={{ fontSize: 'var(--text-sm)' }}
className="tabular-nums"
>
{equipeMes.metaTotal > equipeMes.faturamento
? `Faltam ${fmt(equipeMes.metaTotal - equipeMes.faturamento)}`
: 'Meta atingida 🎉'}
</Text>
</>
) : (
<Text type="secondary">Sem meta cadastrada para o mês.</Text>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(equipeMes.ticketMedio)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
por pedido no mês
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Ranking da equipe */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
Ranking da Equipe &mdash; {mesLabel()}
</Space>
}
>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingRepColumns}
dataSource={rankingReps}
pagination={false}
size="small"
scroll={{ x: 700 }}
locale={{ emptyText: 'Nenhum pedido da equipe no mês.' }}
/>
</Card>
{/* Positivação por representante */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
Positivação de Clientes &mdash; {mesLabel()}
</Space>
}
>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoRepColumns}
dataSource={positivacaoReps}
pagination={false}
size="small"
scroll={{ x: 700 }}
locale={{ emptyText: 'Nenhum representante com carteira na equipe.' }}
/>
</Card>
<Flex justify="space-between" style={{ paddingTop: 8 }}> <Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas SAR · Força de Vendas · Powered by JCS Sistemas
@@ -336,6 +475,8 @@ export function SupervisorPainel() {
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
</Text> </Text>
</Flex> </Flex>
<InativosRepModal rep={inativosRep} onClose={() => setInativosRep(null)} />
</Flex> </Flex>
); );
} }

View File

@@ -0,0 +1,114 @@
import { Flex, Progress, Tag, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import type { PositivacaoRep, RankingRep } from '@sar/api-interface';
const { Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// Colunas compartilhadas entre o painel gerencial e o do supervisor —
// mesmas métricas, mudando só o escopo (empresa toda vs equipe).
export const positivacaoRepColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</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',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
export const rankingRepColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];

View File

@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faChartLine, faChartLine,
faClipboardList, faClipboardList,
faClipboardCheck,
faUsers, faUsers,
faAddressCard, faAddressCard,
faBoxesStacked, faBoxesStacked,
@@ -40,7 +39,6 @@ const REP_ITEMS: NavItem[] = [
const SUPERVISOR_ITEMS: NavItem[] = [ const SUPERVISOR_ITEMS: NavItem[] = [
{ key: '/', icon: faChartLine, label: 'Painel' }, { key: '/', icon: faChartLine, label: 'Painel' },
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' }, { key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' }, { key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' }, { key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },

View File

@@ -12,6 +12,7 @@ import {
faFileInvoiceDollar, faFileInvoiceDollar,
faTags, faTags,
faHeadset, faHeadset,
faGear,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface'; import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store'; import { authStore } from '../../lib/auth-store';
@@ -60,11 +61,6 @@ const REP_ITEMS: ItemType[] = [
const SUPERVISOR_ITEMS: ItemType[] = [ const SUPERVISOR_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' }, { key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{
key: '/aprovacoes',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Aprovações',
},
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' }, { key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{ {
key: '/pedidos', key: '/pedidos',
@@ -91,6 +87,11 @@ const GERENTE_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />, icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
label: 'Clientes', label: 'Clientes',
}, },
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{ {
key: '/relatorios', key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />, icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
@@ -102,6 +103,11 @@ const GERENTE_ITEMS: ItemType[] = [
label: 'Políticas Comerciais', label: 'Políticas Comerciais',
}, },
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' }, { 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[] { function getItems(role: string): ItemType[] {
@@ -116,10 +122,12 @@ export function Sidebar() {
const role = getRole(); const role = getRole();
const items = getItems(role); const items = getItems(role);
const selectedKey = items.find((item) => { const selectedKey = items.reduce<string | undefined>((best, item) => {
const key = (item as { key?: string }).key ?? ''; const key = (item as { key?: string }).key ?? '';
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key); const matches = key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
})?.key as string | undefined; if (!matches) return best;
return !best || key.length > best.length ? key : best;
}, undefined);
return ( return (
<aside <aside

View File

@@ -1,5 +1,5 @@
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface'; 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 // 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 // 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(); const { data: regras } = useRegrasVigentes();
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras); 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 type { CreatePedido } from '@sar/api-interface';
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb'; import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
import { randomUUID } from '../uuid';
export interface PendingOrder { export interface PendingOrder {
idempotencyKey: string; // keyPath do IndexedDB idempotencyKey: string; // keyPath do IndexedDB
@@ -23,7 +24,7 @@ export async function enqueueOrder(
payload: CreatePedido, payload: CreatePedido,
clienteNome: string, clienteNome: string,
): Promise<PendingOrder> { ): Promise<PendingOrder> {
const key = payload.idempotencyKey ?? crypto.randomUUID(); const key = payload.idempotencyKey ?? randomUUID();
const order: PendingOrder = { const order: PendingOrder = {
idempotencyKey: key, idempotencyKey: key,
payload: { ...payload, idempotencyKey: key }, payload: { ...payload, idempotencyKey: key },

View File

@@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
RepDashboardSchema, RepDashboardSchema,
RepInativosDetailSchema,
SupervisorDashboardSchema, SupervisorDashboardSchema,
type RepDashboard, type RepDashboard,
type RepInativosDetail,
type SupervisorDashboard, type SupervisorDashboard,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; import { apiFetch } from '../api-client';
@@ -29,3 +31,30 @@ export function useSupervisorDashboard() {
refetchInterval: 30 * 1000, refetchInterval: 30 * 1000,
}); });
} }
// Painel de um supervisor específico — gerente/admin abrindo o "Detalhar" da
// aba Equipe. Reusa /dashboard/supervisor escopando pelo codSupervisor.
export function useSupervisorDashboardByCod(codSupervisor: number | null) {
return useQuery<SupervisorDashboard>({
queryKey: ['dashboard', 'supervisor', 'detalhe', codSupervisor],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/supervisor?codSupervisor=${codSupervisor}`);
return SupervisorDashboardSchema.parse(raw);
},
enabled: codSupervisor != null,
staleTime: 60 * 1000,
});
}
// Detalhe dos clientes inativos de um rep (modal do card "Inativos por Rep")
export function useRepInativos(codVendedor: number | null) {
return useQuery<RepInativosDetail>({
queryKey: ['dashboard', 'supervisor', 'inativos', codVendedor],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/supervisor/inativos?codVendedor=${codVendedor}`);
return RepInativosDetailSchema.parse(raw);
},
enabled: codVendedor != null,
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -2,15 +2,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
ManagerDashboardSchema, ManagerDashboardSchema,
EquipeResponseSchema, EquipeResponseSchema,
AlcadaDescontosResponseSchema, SupervisoresResponseSchema,
PromocoesResponseSchema, PromocoesResponseSchema,
RegrasDescontoResponseSchema, RegrasDescontoResponseSchema,
GruposProdutoResponseSchema, GruposProdutoResponseSchema,
type ManagerDashboard, type ManagerDashboard,
type EquipeResponse, type EquipeResponse,
type AlcadaDescontosResponse, type SupervisoresResponse,
type PromocoesResponse, type PromocoesResponse,
type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type RegrasDescontoResponse, type RegrasDescontoResponse,
@@ -47,14 +46,14 @@ export function useEquipe() {
}); });
} }
export function usePoliticasDescontos() { export function useSupervisores() {
return useQuery<AlcadaDescontosResponse>({ return useQuery<SupervisoresResponse>({
queryKey: ['politicas', 'descontos'], queryKey: ['equipe', 'supervisores'],
queryFn: async () => { queryFn: async () => {
const raw = await apiFetch('/politicas/descontos'); const raw = await apiFetch('/equipe/supervisores');
return AlcadaDescontosResponseSchema.parse(raw); return SupervisoresResponseSchema.parse(raw);
}, },
staleTime: 10 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
@@ -69,15 +68,6 @@ export function usePromocoes() {
}); });
} }
export function useUpsertDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
});
}
export function useCreatePromocao() { export function useCreatePromocao() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -2,13 +2,16 @@ import { useQuery } from '@tanstack/react-query';
import { import {
RegrasVigentesResponseSchema, RegrasVigentesResponseSchema,
PromocoesVigentesResponseSchema, PromocoesVigentesResponseSchema,
AlcadaRepResponseSchema,
type RegrasVigentesResponse, type RegrasVigentesResponse,
type PromocoesVigentesResponse, type PromocoesVigentesResponse,
type AlcadaRepResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; import { apiFetch } from '../api-client';
// Regras de desconto vigentes para o usuário logado (rep recebe só as suas). // Regras de desconto vigentes para o usuário logado (rep recebe só as suas).
// Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente. // Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente.
// Refetch curto: regra encerrada/inativada some da tela do rep em até 1 minuto.
export function useRegrasVigentes() { export function useRegrasVigentes() {
return useQuery<RegrasVigentesResponse>({ return useQuery<RegrasVigentesResponse>({
queryKey: ['politicas', 'regras-vigentes'], queryKey: ['politicas', 'regras-vigentes'],
@@ -16,11 +19,25 @@ export function useRegrasVigentes() {
const raw = await apiFetch('/politicas/regras/vigentes'); const raw = await apiFetch('/politicas/regras/vigentes');
return RegrasVigentesResponseSchema.parse(raw); return RegrasVigentesResponseSchema.parse(raw);
}, },
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
}
// 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, staleTime: 5 * 60 * 1000,
}); });
} }
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep // Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep.
// Refetch curto: promoção encerrada/inativada some da tela do rep em até 1 minuto.
export function usePromocoesVigentes() { export function usePromocoesVigentes() {
return useQuery<PromocoesVigentesResponse>({ return useQuery<PromocoesVigentesResponse>({
queryKey: ['politicas', 'promocoes-vigentes'], queryKey: ['politicas', 'promocoes-vigentes'],
@@ -28,6 +45,7 @@ export function usePromocoesVigentes() {
const raw = await apiFetch('/politicas/promocoes/vigentes'); const raw = await apiFetch('/politicas/promocoes/vigentes');
return PromocoesVigentesResponseSchema.parse(raw); return PromocoesVigentesResponseSchema.parse(raw);
}, },
staleTime: 5 * 60 * 1000, staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
}); });
} }

View File

@@ -1,12 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
ReportMetaResponseSchema, ReportMetaResponseSchema,
ReportCarteiraResponseSchema,
ReportAbcResponseSchema, ReportAbcResponseSchema,
type ReportMetaQuery, type ReportMetaQuery,
type ReportAbcQuery, type ReportAbcQuery,
type ReportMetaResponse, type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse, type ReportAbcResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; import { apiFetch } from '../api-client';
@@ -19,13 +17,6 @@ export function useReportMeta(params: ReportMetaQuery) {
}); });
} }
export function useReportCarteira() {
return useQuery<ReportCarteiraResponse>({
queryKey: ['reports', 'carteira'],
queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')),
});
}
export function useReportAbc(params: ReportAbcQuery) { export function useReportAbc(params: ReportAbcQuery) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.mes != null) qs.set('mes', String(params.mes)); if (params.mes != null) qs.set('mes', String(params.mes));

View File

@@ -18,15 +18,14 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage'; import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage'; import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { CarteirePage } from '../cockpits/rep/CarteirePage';
import { FunilPage } from '../cockpits/rep/FunilPage'; import { FunilPage } from '../cockpits/rep/FunilPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel'; import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage'; import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage'; import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { ChamadosPage } from '../cockpits/rep/ChamadosPage'; import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage'; import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
import { GerConfigPage } from '../cockpits/ger/GerConfigPage';
import { authStore } from './auth-store'; import { authStore } from './auth-store';
function getRoleFromToken(): string { function getRoleFromToken(): string {
@@ -159,24 +158,12 @@ const relatoriosRoute = createRoute({
component: RelatoriosPage, component: RelatoriosPage,
}); });
const carteiraRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/carteira',
component: CarteirePage,
});
const funilRoute = createRoute({ const funilRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/funil', path: '/funil',
component: FunilPage, component: FunilPage,
}); });
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
component: ApprovalQueuePage,
});
const gerPainelRoute = createRoute({ const gerPainelRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/ger', path: '/ger',
@@ -207,6 +194,12 @@ const gerChamadosRoute = createRoute({
component: GerChamadosPage, component: GerChamadosPage,
}); });
const gerConfigRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/config',
component: GerConfigPage,
});
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
rafaelRoute, rafaelRoute,
@@ -220,14 +213,13 @@ const routeTree = rootRoute.addChildren([
catalogoRoute, catalogoRoute,
pedidosErpRoute, pedidosErpRoute,
relatoriosRoute, relatoriosRoute,
carteiraRoute,
funilRoute, funilRoute,
aprovacoes,
gerPainelRoute, gerPainelRoute,
equipeRoute, equipeRoute,
politicasRoute, politicasRoute,
chamadosRoute, chamadosRoute,
gerChamadosRoute, gerChamadosRoute,
gerConfigRoute,
]); ]);
export const router = createRouter({ 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)}`;
}

14
apps/web/src/svg-maps.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare module '@svg-maps/brazil' {
interface SvgMapLocation {
id: string; // sigla da UF em minúsculas (sp, pr, …)
name: string;
path: string;
}
interface SvgMap {
label: string;
viewBox: string;
locations: SvgMapLocation[];
}
const map: SvgMap;
export default map;
}

View File

@@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({
cep: z.string().nullable(), cep: z.string().nullable(),
telefone: z.string().nullable(), telefone: z.string().nullable(),
email: 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>; 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

@@ -72,20 +72,18 @@ export const RepInativosSummarySchema = z.object({
}); });
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>; export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
export const SupervisorDashboardSchema = z.object({ // Detalhe dos clientes inativos (>30 dias sem compra) de um representante
approvalQueue: z.array(PedidoSummarySchema), export const RepInativosDetailSchema = z.object({
pedidosDia: z.object({ codVendedor: z.number().int(),
count: z.number().int(), nomeVendedor: z.string().nullable(),
total: z.number(), clientes: z.array(ClienteNaoPositivadoSchema),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
syncedAt: z.iso.datetime(),
}); });
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>; export type RepInativosDetail = z.infer<typeof RepInativosDetailSchema>;
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
// caiu no periodo selecionado
export const PositivacaoRepSchema = z.object({ export const PositivacaoRepSchema = z.object({
novosClientes: z.number().int().default(0),
codVendedor: z.number().int(), codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(), nomeVendedor: z.string().nullable(),
totalClientes: z.number().int(), totalClientes: z.number().int(),
@@ -104,6 +102,30 @@ export const RankingRepSchema = z.object({
}); });
export type RankingRep = z.infer<typeof RankingRepSchema>; export type RankingRep = z.infer<typeof RankingRepSchema>;
export const SupervisorDashboardSchema = z.object({
pedidosDia: z.object({
count: z.number().int(),
total: z.number(),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
// Métricas do mês corrente, escopadas à equipe do supervisor
equipeMes: z
.object({
faturamento: z.number(),
pedidos: z.number().int(),
ticketMedio: z.number(),
metaTotal: z.number(),
pctMeta: z.number(),
})
.default({ faturamento: 0, pedidos: 0, ticketMedio: 0, metaTotal: 0, pctMeta: 0 }),
rankingReps: z.array(RankingRepSchema).default([]),
positivacaoReps: z.array(PositivacaoRepSchema).default([]),
syncedAt: z.iso.datetime(),
});
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
// Clientes distintos positivados por dia do período (YYYY-MM-DD) // Clientes distintos positivados por dia do período (YYYY-MM-DD)
export const PositivacaoDiaSchema = z.object({ export const PositivacaoDiaSchema = z.object({
dia: z.string(), dia: z.string(),
@@ -111,6 +133,26 @@ export const PositivacaoDiaSchema = z.object({
}); });
export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>; export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>;
// Faturamento por estado (UF do município do cliente), com detalhe por representante.
export const UfRepSchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
faturamento: z.number(),
pedidos: z.number().int(),
clientes: z.number().int(),
});
export type UfRep = z.infer<typeof UfRepSchema>;
export const UfFaturamentoSchema = z.object({
uf: z.string(), // sigla (SP, PR…); 'ND' = cliente sem município/UF
faturamento: z.number(),
pedidos: z.number().int(),
clientes: z.number().int(),
pct: z.number(), // participação no faturamento do período
reps: z.array(UfRepSchema),
});
export type UfFaturamento = z.infer<typeof UfFaturamentoSchema>;
export const ManagerDashboardSchema = z.object({ export const ManagerDashboardSchema = z.object({
faturamentoMes: z.number(), faturamentoMes: z.number(),
pedidosMes: z.number().int(), pedidosMes: z.number().int(),
@@ -122,6 +164,15 @@ export const ManagerDashboardSchema = z.object({
rankingReps: z.array(RankingRepSchema), rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema), positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]), positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
faturamentoPorUf: z.array(UfFaturamentoSchema).default([]),
// Meta diária de positivação salva pelo gerente (config_empresa)
metaPositivacaoDia: z.number().int().nullable().optional(),
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>; 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

@@ -16,3 +16,30 @@ export const EquipeResponseSchema = z.object({
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>; export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
// Card de disputa/análise por supervisor na aba Equipe (visão gerente/admin).
// Time = reps cujo cod_supervisor aponta para este supervisor + o próprio
// supervisor (mesma semântica de getTeamCodes). Métricas agregadas do time no mês.
export const SupervisorCardSchema = z.object({
codSupervisor: z.number().int(),
nomeSupervisor: z.string().nullable(),
faturamentoMes: z.number(),
faturamentoMesAnterior: z.number(),
variacaoPct: z.number().nullable(), // null quando não há base no mês anterior
pedidosMes: z.number().int(),
ticketMedio: z.number(),
metaTotal: z.number(),
pctMeta: z.number(),
totalClientes: z.number().int(),
clientesPositivados: z.number().int(),
pctPositivacao: z.number(),
repsTotal: z.number().int(),
repsAtivos: z.number().int(), // reps do time que faturaram no mês
});
export type SupervisorCard = z.infer<typeof SupervisorCardSchema>;
export const SupervisoresResponseSchema = z.object({
supervisores: z.array(SupervisorCardSchema),
syncedAt: z.iso.datetime(),
});
export type SupervisoresResponse = z.infer<typeof SupervisoresResponseSchema>;

View File

@@ -6,9 +6,9 @@ import { z } from 'zod';
// ─── Situa ──────────────────────────────────────────────────────────────────── // ─── Situa ────────────────────────────────────────────────────────────────────
// Ciclo de vida do pedido SAR: // Ciclo de vida do pedido SAR: 0=Orçamento → 2=Transmitido.
// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido // Desconto acima da alçada é bloqueio duro na transmissão — não há fila de
// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto). // aprovação. O valor 1 (Ag. Aprovação) é legado, mantido só para dados antigos.
// Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…) // Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…)
// — espelhado quando a integração existir. // — espelhado quando a integração existir.
export const SituaPedidoSchema = z.union([ export const SituaPedidoSchema = z.union([
@@ -40,6 +40,10 @@ export const PedidoItemSchema = z.object({
precoUnitario: z.string(), precoUnitario: z.string(),
descontoPerc: z.string(), descontoPerc: z.string(),
total: 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>; export type PedidoItem = z.infer<typeof PedidoItemSchema>;
@@ -89,6 +93,12 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
pedFlex: z.string(), pedFlex: z.string(),
formaPagamento: z.string().nullable().optional(), formaPagamento: z.string().nullable().optional(),
endEntrega: 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(), aprovadoPor: z.number().int().nullable(),
aprovadoEm: z.iso.datetime().nullable(), aprovadoEm: z.iso.datetime().nullable(),
motivoRecusa: z.string().nullable(), motivoRecusa: z.string().nullable(),
@@ -149,16 +159,16 @@ export const CreatePedidoSchema = z.object({
}); });
export type CreatePedido = z.infer<typeof CreatePedidoSchema>; export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
export const AprovarPedidoSchema = z.object({ // Edição de orçamento (situa 0): mesmo shape da criação, sem idempotencyKey
descontoPerc: z.number().min(0).max(100).optional(), export const UpdatePedidoSchema = CreatePedidoSchema.omit({ idempotencyKey: true });
nota: z.string().optional(), export type UpdatePedido = z.infer<typeof UpdatePedidoSchema>;
});
export type AprovarPedido = z.infer<typeof AprovarPedidoSchema>;
export const RecusarPedidoSchema = z.object({ // Cancelamento com motivo obrigatório + descrição livre opcional
motivo: z.string().min(1), export const CancelPedidoSchema = z.object({
motivo: z.string().min(1).max(60),
descricao: z.string().max(500).optional(),
}); });
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>; export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
// ─── ERP Consulta ───────────────────────────────────────────────────────────── // ─── ERP Consulta ─────────────────────────────────────────────────────────────

View File

@@ -1,25 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export const AlcadaDescontoItemSchema = z.object({
codVendedor: z.number().int(),
codGrupo: z.number().int(),
limitePerc: z.number(),
nomeVendedor: z.string().nullable(),
});
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
export const AlcadaDescontosResponseSchema = z.object({
descontos: z.array(AlcadaDescontoItemSchema),
});
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
export const UpsertDescontoBodySchema = z.object({
codVendedor: z.number().int().positive(),
codGrupo: z.number().int().default(0),
limitePerc: z.number().min(0).max(100),
});
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
export const PromocaoSchema = z.object({ export const PromocaoSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
@@ -39,24 +19,39 @@ export const PromocoesResponseSchema = z.object({
}); });
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>; 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({ export const CreatePromocaoBodySchema = z.object({
descricao: z.string().min(1).max(255), descricao: z.string().min(1).max(255),
codProduto: z.string().optional(), codProdutos: z.array(z.string().min(1)).max(100).optional(),
grpProd: z.string().optional(), grpProds: z.array(z.string().min(1)).max(100).optional(),
descPct: z.number().min(0).max(100), descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dataFim: 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 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(), ativa: z.boolean().optional(),
}); });
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>; export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
// ─── Regras de Desconto ─────────────────────────────────────────────────────── // ─── Regras de Desconto ───────────────────────────────────────────────────────
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de // Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
// produto e/ou representante, com vigência. Campos nulos = "todos". // produto, pauta de preços e/ou representante, com vigência. Campos nulos = "todos".
export const RegraDescontoSchema = z.object({ export const RegraDescontoSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -65,6 +60,8 @@ export const RegraDescontoSchema = z.object({
grupo: z.string().nullable(), grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(), codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(), subgrupo: z.string().nullable(),
idPauta: z.number().int().nullable(),
pauta: z.string().nullable(),
codVendedor: z.number().int().nullable(), codVendedor: z.number().int().nullable(),
nomeVendedor: z.string().nullable(), nomeVendedor: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
@@ -81,10 +78,14 @@ export const RegrasDescontoResponseSchema = z.object({
}); });
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>; export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
// Criação aceita VÁRIOS grupos, subgrupos e/ou pautas — uma regra por alvo
// (cada grupo vira regra por grupo; cada subgrupo, regra por subgrupo; cada
// pauta, regra por pauta). O representante escolhido é copiado em todas.
export const CreateRegraDescontoBodySchema = z.object({ export const CreateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200), descricao: z.string().min(1).max(200),
codGrupo: z.number().int().nullable().optional(), codGrupos: z.array(z.number().int()).max(100).optional(),
codSubgrupo: z.number().int().nullable().optional(), codSubgrupos: z.array(z.number().int()).max(100).optional(),
idPautas: z.array(z.number().int()).max(100).optional(),
codVendedor: z.number().int().nullable().optional(), codVendedor: z.number().int().nullable().optional(),
descPct: z.number().min(0).max(100), descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
@@ -92,17 +93,46 @@ export const CreateRegraDescontoBodySchema = z.object({
}); });
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>; 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(),
idPauta: 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(), ativa: z.boolean().optional(),
}); });
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>; 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 // Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
export const PromocaoVigenteSchema = z.object({ export const PromocaoVigenteSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
codProduto: z.string().nullable(), codProduto: z.string().nullable(),
nomeProduto: z.string().nullable(),
grpProd: z.string().nullable(), grpProd: z.string().nullable(),
grupo: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
dataFim: z.string(), dataFim: z.string(),
}); });
@@ -118,7 +148,11 @@ export const RegraVigenteSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
codGrupo: z.number().int().nullable(), codGrupo: z.number().int().nullable(),
grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(), codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(),
idPauta: z.number().int().nullable(),
pauta: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
dataFim: z.string(), dataFim: z.string(),
}); });

View File

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

8
pnpm-lock.yaml generated
View File

@@ -319,6 +319,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
'@svg-maps/brazil':
specifier: ^2.0.0
version: 2.0.0
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.100.14 specifier: ^5.100.14
version: 5.100.14(react@19.2.6) version: 5.100.14(react@19.2.6)
@@ -3380,6 +3383,9 @@ packages:
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@svg-maps/brazil@2.0.0':
resolution: {integrity: sha512-6le594K1tO6c8Y/0KgWyurksyLnSU/8mTSnr48cKH0eaiG153UQP8aadQ/pKoeCBVd8khLVdmV7wKbkfIAbqBA==}
'@svgr/babel-plugin-add-jsx-attribute@8.0.0': '@svgr/babel-plugin-add-jsx-attribute@8.0.0':
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -13385,6 +13391,8 @@ snapshots:
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@svg-maps/brazil@2.0.0': {}
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7

View File

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

View File

@@ -289,7 +289,9 @@ SELECT
COALESCE(p.preco_promocional, 0) AS preco_promocional, COALESCE(p.preco_promocional, 0) AS preco_promocional,
COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote, COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote,
p.lista_pauta, 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 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 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 LEFT JOIN gestao.grupo sub ON sub.codigo = p.cod_subgrupo AND sub.id_empresa = p.id_empresa
@@ -718,8 +720,9 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin) -- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com -- Desconto liberado por grupo/subgrupo de produto, pauta de precos e/ou
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep). -- representante, com vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL
-- vale para todo rep; id_pauta NULL vale para qualquer pauta).
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.regras_desconto ( CREATE TABLE IF NOT EXISTS sar.regras_desconto (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -728,6 +731,7 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
cod_grupo INTEGER, cod_grupo INTEGER,
cod_subgrupo INTEGER, cod_subgrupo INTEGER,
cod_vendedor INTEGER, cod_vendedor INTEGER,
id_pauta INTEGER,
desc_pct NUMERIC(5,2) NOT NULL, desc_pct NUMERIC(5,2) NOT NULL,
data_inicio DATE NOT NULL, data_inicio DATE NOT NULL,
data_fim DATE NOT NULL, data_fim DATE NOT NULL,
@@ -736,6 +740,17 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
created_by VARCHAR(100) NOT NULL 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) -- Oportunidades (Funil de Vendas)
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
@@ -820,6 +835,6 @@ CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagen
-- GRANT INSERT, UPDATE, DELETE ON -- GRANT INSERT, UPDATE, DELETE ON
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido, -- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription, -- 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>; -- TO <role>;
-- ============================================================================= -- =============================================================================