Merge branch 'fix/rep-authz-e-front': regras de desconto, usuarios dev por papel e escopo do supervisor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais)
|
||||||
|
-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou
|
||||||
|
-- representante, com vigencia. Campos nulos = "todos".
|
||||||
|
-- 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.regras_desconto (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
descricao VARCHAR(200) NOT NULL,
|
||||||
|
cod_grupo INTEGER,
|
||||||
|
cod_subgrupo INTEGER,
|
||||||
|
cod_vendedor INTEGER,
|
||||||
|
desc_pct NUMERIC(5,2) NOT NULL,
|
||||||
|
data_inicio DATE NOT NULL,
|
||||||
|
data_fim DATE NOT NULL,
|
||||||
|
ativa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
|
||||||
@@ -171,6 +171,30 @@ model Promocao {
|
|||||||
@@map("promocoes")
|
@@map("promocoes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
|
||||||
|
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos".
|
||||||
|
|
||||||
|
model RegraDesconto {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
descricao String
|
||||||
|
codGrupo Int? @map("cod_grupo")
|
||||||
|
codSubgrupo Int? @map("cod_subgrupo")
|
||||||
|
codVendedor Int? @map("cod_vendedor")
|
||||||
|
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||||
|
dataInicio DateTime @db.Date @map("data_inicio")
|
||||||
|
dataFim DateTime @db.Date @map("data_fim")
|
||||||
|
ativa Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String @map("created_by")
|
||||||
|
|
||||||
|
@@index([idEmpresa])
|
||||||
|
@@index([idEmpresa, dataFim])
|
||||||
|
@@map("regras_desconto")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 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)
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export class DevAuthController {
|
|||||||
private readonly secret: Uint8Array;
|
private readonly secret: Uint8Array;
|
||||||
private readonly expiresIn: number;
|
private readonly expiresIn: number;
|
||||||
private readonly isProd: boolean;
|
private readonly isProd: boolean;
|
||||||
|
private readonly devEmpresaId: number;
|
||||||
|
|
||||||
constructor(config: ConfigService<Env, true>) {
|
constructor(config: ConfigService<Env, true>) {
|
||||||
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||||
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
||||||
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||||
|
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
@@ -33,7 +35,7 @@ export class DevAuthController {
|
|||||||
if (this.isProd) throw new NotFoundException();
|
if (this.isProd) throw new NotFoundException();
|
||||||
|
|
||||||
const accessToken = await new SignJWT({
|
const accessToken = await new SignJWT({
|
||||||
id_empresa: dto.idEmpresa,
|
id_empresa: dto.idEmpresa ?? this.devEmpresaId,
|
||||||
role: dto.role,
|
role: dto.role,
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
|||||||
@@ -51,10 +51,15 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
||||||
|
|
||||||
// Em dev: força representante fixo (DEV_REP_CODE / DEV_EMPRESA_ID) ignorando o JWT.
|
// Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
|
||||||
// Em prod: usa os valores reais do JWT.
|
// que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
|
||||||
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId;
|
// DEV_EMPRESA_ID apenas se o token não trouxer os campos.
|
||||||
const userId = this.isProd ? payload.sub : this.devRepCode;
|
// Em prod: usa os valores reais do JWT do master-login.
|
||||||
|
const idEmpresa = payload.id_empresa ?? (this.isProd ? undefined : this.devEmpresaId);
|
||||||
|
const userId = payload.sub ?? (this.isProd ? undefined : this.devRepCode);
|
||||||
|
if (idEmpresa == null || userId == null) {
|
||||||
|
throw new UnauthorizedException('token sem sub/id_empresa');
|
||||||
|
}
|
||||||
this.cls.set('idEmpresa', idEmpresa);
|
this.cls.set('idEmpresa', idEmpresa);
|
||||||
this.cls.set('userId', userId);
|
this.cls.set('userId', userId);
|
||||||
this.cls.set('role', payload.role);
|
this.cls.set('role', payload.role);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
TopProduto,
|
TopProduto,
|
||||||
} 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';
|
||||||
|
|
||||||
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
||||||
const ALERT_DAYS = 30;
|
const ALERT_DAYS = 30;
|
||||||
@@ -36,6 +37,12 @@ function escSql(s: string): string {
|
|||||||
return s.replace(/'/g, "''");
|
return s.replace(/'/g, "''");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para
|
||||||
|
// origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe.
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
// Row bruta do $queryRawUnsafe
|
// Row bruta do $queryRawUnsafe
|
||||||
interface ClientRow {
|
interface ClientRow {
|
||||||
id_cliente: number;
|
id_cliente: number;
|
||||||
@@ -108,6 +115,29 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
|
|||||||
export class ClientsService {
|
export class ClientsService {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: rep só acessa clientes da própria carteira; supervisor acessa as
|
||||||
|
// carteiras da sua equipe (cod_supervisor); gerente/admin passam direto.
|
||||||
|
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
|
||||||
|
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'rep' && 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');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
idCliente,
|
||||||
|
);
|
||||||
|
const dono = rows[0] ? Number(rows[0].cod_vendedor) : null;
|
||||||
|
const permitidos =
|
||||||
|
role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [codVendedor];
|
||||||
|
if (dono == null || !permitidos.includes(dono)) {
|
||||||
|
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
||||||
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');
|
||||||
@@ -119,8 +149,13 @@ export class ClientsService {
|
|||||||
const { q, status, page, limit } = query;
|
const { q, status, page, limit } = query;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Rep vê apenas sua carteira (cod_vendedor = seu código)
|
// Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
|
||||||
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
|
const vendedorFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? `AND c.cod_vendedor = ${codVendedor}`
|
||||||
|
: role === 'supervisor'
|
||||||
|
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||||
|
: '';
|
||||||
const searchFilter = q
|
const searchFilter = q
|
||||||
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
|
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
|
||||||
: '';
|
: '';
|
||||||
@@ -257,6 +292,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listContacts(idCorrent: number): Promise<Contato[]> {
|
async listContacts(idCorrent: number): Promise<Contato[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
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');
|
||||||
|
|
||||||
@@ -316,6 +352,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
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');
|
||||||
@@ -337,7 +374,7 @@ export class ClientsService {
|
|||||||
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
||||||
'${esc(dto.nome)}',
|
'${esc(dto.nome)}',
|
||||||
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
||||||
${dto.dtAniversario ? `'${dto.dtAniversario}'` : 'NULL'},
|
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
|
||||||
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
||||||
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
||||||
)
|
)
|
||||||
@@ -370,6 +407,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
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');
|
||||||
@@ -430,12 +468,13 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
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');
|
||||||
|
|
||||||
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
||||||
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
id_produto: number;
|
id_produto: number;
|
||||||
@@ -493,8 +532,10 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
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 idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
qtd_aberto: string;
|
qtd_aberto: string;
|
||||||
@@ -513,6 +554,7 @@ export class ClientsService {
|
|||||||
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
||||||
FROM sar.vw_ctr
|
FROM sar.vw_ctr
|
||||||
WHERE id_cliente = ${idCliente}
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND id_empresa = ${idEmpresaMatriz}
|
||||||
AND situacao = 'A'
|
AND situacao = 'A'
|
||||||
AND saldo > 0
|
AND saldo > 0
|
||||||
`);
|
`);
|
||||||
@@ -528,6 +570,7 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findOne(idCliente: number): Promise<ClientDetail> {
|
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
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');
|
||||||
@@ -579,8 +622,10 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
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 idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
type CtrRow = {
|
type CtrRow = {
|
||||||
id_ctr: number;
|
id_ctr: number;
|
||||||
@@ -602,7 +647,7 @@ export class ClientsService {
|
|||||||
saldo::text AS saldo
|
saldo::text AS saldo
|
||||||
FROM sar.vw_ctr
|
FROM sar.vw_ctr
|
||||||
WHERE id_cliente = $1
|
WHERE id_cliente = $1
|
||||||
AND id_empresa = 1
|
AND id_empresa = $3
|
||||||
AND situacao = 'A'
|
AND situacao = 'A'
|
||||||
AND saldo > 0
|
AND saldo > 0
|
||||||
ORDER BY dt_vencimento ASC
|
ORDER BY dt_vencimento ASC
|
||||||
@@ -610,6 +655,7 @@ export class ClientsService {
|
|||||||
`,
|
`,
|
||||||
idCliente,
|
idCliente,
|
||||||
limit,
|
limit,
|
||||||
|
idEmpresaMatriz,
|
||||||
);
|
);
|
||||||
|
|
||||||
const toDate = (d: Date | string): string =>
|
const toDate = (d: Date | string): string =>
|
||||||
@@ -626,8 +672,12 @@ export class ClientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
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');
|
||||||
|
// NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000)
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
const idEmpresaFiscal = idEmpresaMatriz + 9000;
|
||||||
|
|
||||||
type NfRow = {
|
type NfRow = {
|
||||||
id_nf: number;
|
id_nf: number;
|
||||||
@@ -653,9 +703,9 @@ export class ClientsService {
|
|||||||
JOIN sar.vw_pedidos_erp p
|
JOIN sar.vw_pedidos_erp p
|
||||||
ON p.numero = nf.num_entrega
|
ON p.numero = nf.num_entrega
|
||||||
AND p.tipo = 'E'
|
AND p.tipo = 'E'
|
||||||
AND p.id_empresa IN (1, 9001)
|
AND p.id_empresa IN ($3, $4)
|
||||||
WHERE p.id_cliente = $1
|
WHERE p.id_cliente = $1
|
||||||
AND nf.id_empresa = 1
|
AND nf.id_empresa = $3
|
||||||
AND nf.status = 'E'
|
AND nf.status = 'E'
|
||||||
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||||
ORDER BY nf.id_nf
|
ORDER BY nf.id_nf
|
||||||
@@ -665,6 +715,8 @@ export class ClientsService {
|
|||||||
`,
|
`,
|
||||||
idCliente,
|
idCliente,
|
||||||
limit,
|
limit,
|
||||||
|
idEmpresaMatriz,
|
||||||
|
idEmpresaFiscal,
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map((r: NfRow) => ({
|
return rows.map((r: NfRow) => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
@@ -11,6 +11,15 @@ export class DashboardController {
|
|||||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: dashboards gerenciais agregam dados da empresa toda (faturamento,
|
||||||
|
// ranking de todos os reps) — rep não pode acessá-los.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException('Apenas supervisores e gerentes podem acessar este painel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('rep')
|
@Get('rep')
|
||||||
repDashboard(): Promise<RepDashboard> {
|
repDashboard(): Promise<RepDashboard> {
|
||||||
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
||||||
@@ -18,6 +27,7 @@ export class DashboardController {
|
|||||||
|
|
||||||
@Get('supervisor')
|
@Get('supervisor')
|
||||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
return this.dashboard.supervisorDashboard();
|
return this.dashboard.supervisorDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +36,7 @@ export class DashboardController {
|
|||||||
@Query('mes') mes?: string,
|
@Query('mes') mes?: string,
|
||||||
@Query('ano') ano?: string,
|
@Query('ano') ano?: string,
|
||||||
): Promise<ManagerDashboard> {
|
): Promise<ManagerDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
return this.dashboard.managerDashboard(
|
return this.dashboard.managerDashboard(
|
||||||
mes ? parseInt(mes, 10) : undefined,
|
mes ? parseInt(mes, 10) : undefined,
|
||||||
ano ? parseInt(ano, 10) : undefined,
|
ano ? parseInt(ano, 10) : undefined,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
PositivacaoRep,
|
PositivacaoRep,
|
||||||
} 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';
|
||||||
|
|
||||||
// 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
|
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||||
@@ -413,6 +414,10 @@ export class DashboardService {
|
|||||||
total_clientes: string;
|
total_clientes: string;
|
||||||
clientes_positivados: string;
|
clientes_positivados: string;
|
||||||
}
|
}
|
||||||
|
interface PositivacaoDiaRow {
|
||||||
|
dia: string;
|
||||||
|
clientes: string;
|
||||||
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
statsRows,
|
statsRows,
|
||||||
@@ -422,6 +427,7 @@ export class DashboardService {
|
|||||||
positivacaoRows,
|
positivacaoRows,
|
||||||
metaGrupoRows,
|
metaGrupoRows,
|
||||||
realizadoGrupoRows,
|
realizadoGrupoRows,
|
||||||
|
positivacaoDiariaRows,
|
||||||
] = 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
|
||||||
@@ -518,6 +524,17 @@ export class DashboardService {
|
|||||||
AND e.dt_pedido <= '${monthEndStr}'
|
AND e.dt_pedido <= '${monthEndStr}'
|
||||||
GROUP BY p.cod_grupo, grupo
|
GROUP BY p.cod_grupo, grupo
|
||||||
`),
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PositivacaoDiaRow[]>(`
|
||||||
|
SELECT dt_pedido::date::text AS dia,
|
||||||
|
COUNT(DISTINCT id_cliente)::text AS clientes
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY dt_pedido::date
|
||||||
|
ORDER BY dia
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
||||||
@@ -590,6 +607,10 @@ export class DashboardService {
|
|||||||
metasPorGrupo,
|
metasPorGrupo,
|
||||||
rankingReps,
|
rankingReps,
|
||||||
positivacaoReps,
|
positivacaoReps,
|
||||||
|
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
|
||||||
|
dia: r.dia,
|
||||||
|
clientes: Number(r.clientes),
|
||||||
|
})),
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -600,9 +621,21 @@ export class DashboardService {
|
|||||||
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);
|
||||||
|
// gerente/admin acessando este painel veem a empresa toda.
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const team =
|
||||||
|
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
||||||
|
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
||||||
|
|
||||||
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
|
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
|
||||||
const approvalQueue = await prisma.pedido.findMany({
|
const approvalQueue = await prisma.pedido.findMany({
|
||||||
where: { idEmpresa, situa: SITUA_PENDENTE },
|
where: {
|
||||||
|
idEmpresa,
|
||||||
|
situa: SITUA_PENDENTE,
|
||||||
|
...(team ? { codVendedor: { in: team } } : {}),
|
||||||
|
},
|
||||||
orderBy: { dtPedido: 'asc' },
|
orderBy: { dtPedido: 'asc' },
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
@@ -626,12 +659,14 @@ export class DashboardService {
|
|||||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
|
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
`),
|
`),
|
||||||
prisma.$queryRawUnsafe<DayRow[]>(`
|
prisma.$queryRawUnsafe<DayRow[]>(`
|
||||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
WHERE id_empresa = ${idEmpresa} AND situa != 5
|
WHERE id_empresa = ${idEmpresa} AND situa != 5
|
||||||
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
|
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
`),
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -651,6 +686,7 @@ export class DashboardService {
|
|||||||
AND p.id_empresa = ${idEmpresa}
|
AND p.id_empresa = ${idEmpresa}
|
||||||
AND p.situa != 5
|
AND p.situa != 5
|
||||||
WHERE c.ativo = 1
|
WHERE c.ativo = 1
|
||||||
|
${teamSqlFilter('c.cod_vendedor')}
|
||||||
GROUP BY c.id_cliente, c.cod_vendedor
|
GROUP BY c.id_cliente, c.cod_vendedor
|
||||||
HAVING MAX(p.dt_pedido) IS NULL
|
HAVING MAX(p.dt_pedido) IS NULL
|
||||||
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface';
|
import {
|
||||||
|
SubscribePayloadSchema,
|
||||||
|
UnsubscribePayloadSchema,
|
||||||
|
type SubscribePayload,
|
||||||
|
} from '@sar/api-interface';
|
||||||
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
||||||
|
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
|
||||||
|
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
@@ -18,8 +23,8 @@ export class NotificationsController {
|
|||||||
|
|
||||||
@Delete('unsubscribe')
|
@Delete('unsubscribe')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
|
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
|
||||||
await this.svc.unsubscribe(body.endpoint);
|
await this.svc.unsubscribe(req.user.sub, body.endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('pending-count')
|
@Get('pending-count')
|
||||||
|
|||||||
@@ -40,11 +40,15 @@ export class NotificationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async unsubscribe(endpoint: string): Promise<void> {
|
// Escopado ao dono: só remove subscription do próprio usuário — quem souber o
|
||||||
|
// endpoint de terceiro não consegue desregistrá-lo.
|
||||||
|
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||||
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 codVendedor = userId ? parseInt(userId, 10) : null;
|
||||||
|
|
||||||
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
|
await prisma.pushSubscription.deleteMany({ where: { endpoint, idEmpresa, codVendedor } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
RecusarPedido,
|
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 { 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, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
|
||||||
@@ -51,9 +52,13 @@ export class OrdersService {
|
|||||||
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Rep vê só os próprios pedidos; supervisor vê os da sua equipe
|
||||||
|
// (cod_supervisor em vw_representantes); gerente/admin veem tudo.
|
||||||
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [];
|
||||||
const where: Prisma.PedidoWhereInput = {
|
const where: Prisma.PedidoWhereInput = {
|
||||||
idEmpresa,
|
idEmpresa,
|
||||||
...(role === 'rep' ? { codVendedor } : {}),
|
...(role === 'rep' ? { codVendedor } : {}),
|
||||||
|
...(role === 'supervisor' ? { codVendedor: { in: team } } : {}),
|
||||||
...(idCliente != null ? { idCliente } : {}),
|
...(idCliente != null ? { idCliente } : {}),
|
||||||
...(situa != null ? { situa } : {}),
|
...(situa != null ? { situa } : {}),
|
||||||
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
||||||
@@ -127,7 +132,19 @@ export class OrdersService {
|
|||||||
dataHistorico.setDate(dataHistorico.getDate() - 90);
|
dataHistorico.setDate(dataHistorico.getDate() - 90);
|
||||||
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
|
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
|
||||||
|
|
||||||
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
|
// Defesa em profundidade: o contrato já exige YYYY-MM-DD, mas como as datas
|
||||||
|
// são interpoladas no SQL abaixo, revalidamos antes de montar a query.
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if ((from && !DATE_RE.test(from)) || (to && !DATE_RE.test(to))) {
|
||||||
|
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendedorFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? `AND a.cod_vendedor = ${codVendedor}`
|
||||||
|
: role === 'supervisor'
|
||||||
|
? `AND a.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||||
|
: '';
|
||||||
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
|
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
|
||||||
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
|
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
|
||||||
const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : '';
|
const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : '';
|
||||||
@@ -271,6 +288,20 @@ 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');
|
||||||
@@ -279,7 +310,12 @@ export class OrdersService {
|
|||||||
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 repFilter = role === 'rep' ? { codVendedor } : {};
|
const repFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? { codVendedor }
|
||||||
|
: role === 'supervisor'
|
||||||
|
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
|
||||||
|
: {};
|
||||||
|
|
||||||
const o = await prisma.pedido.findFirst({
|
const o = await prisma.pedido.findFirst({
|
||||||
where: { id, idEmpresa, ...repFilter },
|
where: { id, idEmpresa, ...repFilter },
|
||||||
@@ -301,13 +337,26 @@ export class OrdersService {
|
|||||||
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);
|
||||||
|
|
||||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
// PGD-AUTHZ: rep só lança pedido para cliente da própria carteira.
|
||||||
|
if (role === 'rep') {
|
||||||
|
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
dto.idCliente,
|
||||||
|
);
|
||||||
|
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||||
|
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency-Key: retorna pedido existente sem re-processar.
|
||||||
|
// Escopado a empresa + vendedor — chave de terceiro não vaza pedido alheio.
|
||||||
if (dto.idempotencyKey) {
|
if (dto.idempotencyKey) {
|
||||||
const existing = await prisma.pedido.findUnique({
|
const existing = await prisma.pedido.findFirst({
|
||||||
where: { idempotencyKey: dto.idempotencyKey },
|
where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
|
||||||
include: {
|
include: {
|
||||||
itens: { orderBy: { ordem: 'asc' } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
historico: { orderBy: { changedAt: 'asc' } },
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
@@ -399,7 +448,10 @@ export class OrdersService {
|
|||||||
|
|
||||||
// Rep só transmite o próprio orçamento
|
// Rep só transmite o próprio orçamento
|
||||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
const pedido = await prisma.pedido.findFirst({
|
||||||
|
where: { id, idEmpresa, ...repFilter },
|
||||||
|
include: { itens: { orderBy: { ordem: 'asc' } } },
|
||||||
|
});
|
||||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -417,6 +469,8 @@ export class OrdersService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||||
await prisma.historicoPedido.create({
|
await prisma.historicoPedido.create({
|
||||||
@@ -440,6 +494,146 @@ export class OrdersService {
|
|||||||
return this.mapDetail(final);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
||||||
|
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
|
||||||
|
// global — deve caber no limite do grupo do produto (alcada_desconto por
|
||||||
|
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
|
||||||
|
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o
|
||||||
|
// desconto lançado no item E desconto disfarçado de preço unitário reduzido.
|
||||||
|
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem
|
||||||
|
// referência valida só pelo campo de desconto.
|
||||||
|
private async assertAlcadaItens(
|
||||||
|
pedido: {
|
||||||
|
codVendedor: number;
|
||||||
|
descontoPerc: Prisma.Decimal;
|
||||||
|
idPauta: number | null;
|
||||||
|
itens: {
|
||||||
|
ordem: number;
|
||||||
|
idProduto: number;
|
||||||
|
codProduto: string | null;
|
||||||
|
precoUnitario: Prisma.Decimal;
|
||||||
|
descontoPerc: Prisma.Decimal;
|
||||||
|
}[];
|
||||||
|
},
|
||||||
|
limitMap: Map<number, number>,
|
||||||
|
limiteDefault: 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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
if (pedido.itens.length === 0) return;
|
||||||
|
const prodIds = [...new Set(pedido.itens.map((i) => i.idProduto))];
|
||||||
|
|
||||||
|
interface ProdRow {
|
||||||
|
id_erp: number;
|
||||||
|
codigo: string | null;
|
||||||
|
cod_grupo: number | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
vl_preco1: string | null;
|
||||||
|
preco_promocional: string | null;
|
||||||
|
preco_pauta: string | null;
|
||||||
|
}
|
||||||
|
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
|
||||||
|
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
|
||||||
|
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo, p.cod_subgrupo,
|
||||||
|
p.vl_preco1::text, p.preco_promocional::text,
|
||||||
|
${
|
||||||
|
pedido.idPauta != null
|
||||||
|
? `(SELECT pp.preco1::text FROM vw_pauta_produtos pp
|
||||||
|
WHERE pp.id_pauta = ${Number(pedido.idPauta)}
|
||||||
|
AND pp.id_produto = p.id_erp LIMIT 1)`
|
||||||
|
: 'NULL'
|
||||||
|
} AS preco_pauta
|
||||||
|
FROM vw_produtos p
|
||||||
|
WHERE p.id_empresa = ${idMatriz}
|
||||||
|
AND p.id_erp IN (${prodIds.join(',')})
|
||||||
|
`);
|
||||||
|
const prodMap = new Map(prodRows.map((p) => [Number(p.id_erp), p]));
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const promos = await prisma.promocao.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regras de desconto vigentes que alcançam o rep dono do pedido
|
||||||
|
// (cod_vendedor nulo = todos os reps).
|
||||||
|
const regras = await prisma.regraDesconto.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
OR: [{ codVendedor: null }, { codVendedor: pedido.codVendedor }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const descGlobal = Number(pedido.descontoPerc);
|
||||||
|
const problemas: string[] = [];
|
||||||
|
|
||||||
|
for (const it of pedido.itens) {
|
||||||
|
const prod = prodMap.get(it.idProduto);
|
||||||
|
const codGrupo = prod?.cod_grupo != null ? Number(prod.cod_grupo) : null;
|
||||||
|
const codigo = prod?.codigo?.trim() || it.codProduto || String(it.idProduto);
|
||||||
|
|
||||||
|
const promoPct = promos
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
(p.codProduto && prod?.codigo && p.codProduto.trim() === prod.codigo.trim()) ||
|
||||||
|
(p.grpProd && codGrupo != null && p.grpProd.trim() === String(codGrupo)),
|
||||||
|
)
|
||||||
|
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||||
|
|
||||||
|
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou
|
||||||
|
// subgrupo, o produto precisa casar com o que a regra especifica.
|
||||||
|
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
|
||||||
|
const regraPct = 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, Number(r.descPct)), 0);
|
||||||
|
|
||||||
|
const limiteItem =
|
||||||
|
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
|
||||||
|
Math.max(promoPct, regraPct);
|
||||||
|
|
||||||
|
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
||||||
|
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
||||||
|
const precoBase = precoPauta > 0 ? precoPauta : Number(prod?.vl_preco1 ?? 0);
|
||||||
|
const precoPromo = Number(prod?.preco_promocional ?? 0);
|
||||||
|
const tabela = precoPromo > 0 && precoPromo < precoBase ? precoPromo : precoBase;
|
||||||
|
|
||||||
|
const descItem = Number(it.descontoPerc);
|
||||||
|
const precoLiquido = Number(it.precoUnitario) * (1 - descItem / 100) * (1 - descGlobal / 100);
|
||||||
|
|
||||||
|
// Desconto efetivo: vs tabela quando há referência; senão só os percentuais
|
||||||
|
const descEfetivo =
|
||||||
|
tabela > 0
|
||||||
|
? (1 - precoLiquido / tabela) * 100
|
||||||
|
: (1 - (1 - descItem / 100) * (1 - descGlobal / 100)) * 100;
|
||||||
|
|
||||||
|
if (descEfetivo > limiteItem + 0.01) {
|
||||||
|
problemas.push(
|
||||||
|
`item ${it.ordem} (${codigo}) com desconto efetivo de ${descEfetivo.toFixed(1)}% — máximo permitido ${limiteItem}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problemas.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
async approve(id: string, dto: AprovarPedido): 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');
|
||||||
@@ -453,6 +647,7 @@ export class OrdersService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||||
);
|
);
|
||||||
|
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
||||||
@@ -511,6 +706,7 @@ export class OrdersService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||||
);
|
);
|
||||||
|
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -754,7 +950,12 @@ export class OrdersService {
|
|||||||
total: string;
|
total: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
|
const vendedorFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? `AND e.cod_vendedor = ${codVendedor}`
|
||||||
|
: role === 'supervisor'
|
||||||
|
? `AND e.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||||
|
: '';
|
||||||
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
const [headerRows, itemRows] = await Promise.all([
|
const [headerRows, itemRows] = await Promise.all([
|
||||||
|
|||||||
@@ -14,17 +14,27 @@ import {
|
|||||||
UpsertDescontoBodySchema,
|
UpsertDescontoBodySchema,
|
||||||
CreatePromocaoBodySchema,
|
CreatePromocaoBodySchema,
|
||||||
UpdatePromocaoBodySchema,
|
UpdatePromocaoBodySchema,
|
||||||
|
CreateRegraDescontoBodySchema,
|
||||||
|
UpdateRegraDescontoBodySchema,
|
||||||
type UpsertDescontoBody,
|
type UpsertDescontoBody,
|
||||||
type CreatePromocaoBody,
|
type CreatePromocaoBody,
|
||||||
type UpdatePromocaoBody,
|
type UpdatePromocaoBody,
|
||||||
|
type CreateRegraDescontoBody,
|
||||||
|
type UpdateRegraDescontoBody,
|
||||||
type AlcadaDescontosResponse,
|
type AlcadaDescontosResponse,
|
||||||
type PromocoesResponse,
|
type PromocoesResponse,
|
||||||
|
type PromocoesVigentesResponse,
|
||||||
|
type RegrasDescontoResponse,
|
||||||
|
type RegrasVigentesResponse,
|
||||||
|
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 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 UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'politicas' })
|
@Controller({ path: 'politicas' })
|
||||||
export class PoliticasController {
|
export class PoliticasController {
|
||||||
@@ -46,6 +56,11 @@ export class PoliticasController {
|
|||||||
return this.politicas.listPromocoes();
|
return this.politicas.listPromocoes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('promocoes/vigentes')
|
||||||
|
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
return this.politicas.listPromocoesVigentes();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('promocoes')
|
@Post('promocoes')
|
||||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||||
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||||
@@ -64,4 +79,38 @@ export class PoliticasController {
|
|||||||
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
return this.politicas.deletePromocao(id);
|
return this.politicas.deletePromocao(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('regras')
|
||||||
|
listRegras(): Promise<RegrasDescontoResponse> {
|
||||||
|
return this.politicas.listRegras();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('regras/vigentes')
|
||||||
|
listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||||
|
return this.politicas.listRegrasVigentes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('regras')
|
||||||
|
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> {
|
||||||
|
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('regras/:id')
|
||||||
|
updateRegra(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: UpdateRegraDescontoDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('regras/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
deleteRegra(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.politicas.deleteRegra(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('grupos-produto')
|
||||||
|
listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||||
|
return this.politicas.listGruposProduto();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import type {
|
|||||||
PromocoesResponse,
|
PromocoesResponse,
|
||||||
CreatePromocaoBody,
|
CreatePromocaoBody,
|
||||||
UpdatePromocaoBody,
|
UpdatePromocaoBody,
|
||||||
|
RegrasDescontoResponse,
|
||||||
|
CreateRegraDescontoBody,
|
||||||
|
UpdateRegraDescontoBody,
|
||||||
|
RegrasVigentesResponse,
|
||||||
|
PromocoesVigentesResponse,
|
||||||
|
GruposProdutoResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
@@ -158,4 +164,219 @@ export class PoliticasService {
|
|||||||
|
|
||||||
await prisma.promocao.delete({ where: { id } });
|
await prisma.promocao.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Regras de Desconto ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e
|
||||||
|
// resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código.
|
||||||
|
private async loadGruposProduto(): Promise<
|
||||||
|
{
|
||||||
|
cod_grupo: number | null;
|
||||||
|
grupo: string | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
subgrupo: string | null;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
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;
|
||||||
|
|
||||||
|
return prisma.$queryRawUnsafe(
|
||||||
|
`SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo
|
||||||
|
FROM vw_produtos
|
||||||
|
WHERE id_empresa = ${idMatriz}
|
||||||
|
AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL)
|
||||||
|
ORDER BY grupo, subgrupo`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
const rows = await this.loadGruposProduto();
|
||||||
|
return {
|
||||||
|
grupos: rows.map((r) => ({
|
||||||
|
codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null,
|
||||||
|
grupo: r.grupo,
|
||||||
|
codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null,
|
||||||
|
subgrupo: r.subgrupo,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRegras(): Promise<RegrasDescontoResponse> {
|
||||||
|
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.regraDesconto.findMany({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const codigos = [
|
||||||
|
...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)),
|
||||||
|
];
|
||||||
|
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]));
|
||||||
|
|
||||||
|
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]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
regras: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
descricao: r.descricao,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||||
|
codSubgrupo: r.codSubgrupo,
|
||||||
|
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||||
|
codVendedor: r.codVendedor,
|
||||||
|
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||||
|
descPct: Number(r.descPct),
|
||||||
|
dataInicio: r.dataInicio.toISOString().slice(0, 10),
|
||||||
|
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||||
|
ativa: r.ativa,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId') ?? 'system';
|
||||||
|
|
||||||
|
const r = await prisma.regraDesconto.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
descricao: body.descricao,
|
||||||
|
codGrupo: body.codGrupo ?? null,
|
||||||
|
codSubgrupo: body.codSubgrupo ?? null,
|
||||||
|
codVendedor: body.codVendedor ?? null,
|
||||||
|
descPct: body.descPct,
|
||||||
|
dataInicio: new Date(body.dataInicio),
|
||||||
|
dataFim: new Date(body.dataFim),
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: r.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRegra(id: number, body: UpdateRegraDescontoBody): 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');
|
||||||
|
|
||||||
|
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||||
|
|
||||||
|
await prisma.regraDesconto.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||||
|
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||||
|
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||||
|
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||||
|
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||||
|
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||||
|
...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }),
|
||||||
|
...(body.ativa !== undefined && { ativa: body.ativa }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegra(id: number): 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');
|
||||||
|
|
||||||
|
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||||
|
|
||||||
|
await prisma.regraDesconto.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||||
|
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||||
|
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
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 hoje = new Date();
|
||||||
|
const rows = await prisma.promocao.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
},
|
||||||
|
orderBy: [{ descPct: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promocoes: rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
descricao: p.descricao,
|
||||||
|
codProduto: p.codProduto,
|
||||||
|
grpProd: p.grpProd,
|
||||||
|
descPct: Number(p.descPct),
|
||||||
|
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o
|
||||||
|
// alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas.
|
||||||
|
async listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||||
|
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 role = this.cls.get('role') ?? 'rep';
|
||||||
|
const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const rows = await prisma.regraDesconto.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ descPct: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
regras: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
descricao: r.descricao,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
codSubgrupo: r.codSubgrupo,
|
||||||
|
descPct: Number(r.descPct),
|
||||||
|
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
AbcProduto,
|
AbcProduto,
|
||||||
} 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';
|
||||||
|
|
||||||
function d(v: unknown): string {
|
function d(v: unknown): string {
|
||||||
return v != null ? String(v) : '0';
|
return v != null ? String(v) : '0';
|
||||||
@@ -77,6 +78,12 @@ export class ReportsService {
|
|||||||
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;
|
||||||
|
|
||||||
|
// Rep vê a própria carteira; supervisor vê as carteiras da equipe
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||||
|
const vendCond = (col: string) =>
|
||||||
|
team ? `${col} IN (${team.join(',')})` : `${col} = ${codVendedor}`;
|
||||||
|
|
||||||
interface CarteiraRow {
|
interface CarteiraRow {
|
||||||
id_cliente: unknown;
|
id_cliente: unknown;
|
||||||
nome: string | null;
|
nome: string | null;
|
||||||
@@ -102,7 +109,7 @@ export class ReportsService {
|
|||||||
COALESCE(SUM(total), 0)::numeric AS faturamento_total
|
COALESCE(SUM(total), 0)::numeric AS faturamento_total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
WHERE id_empresa = ${idEmpresa}
|
WHERE id_empresa = ${idEmpresa}
|
||||||
AND cod_vendedor = ${codVendedor}
|
AND ${vendCond('cod_vendedor')}
|
||||||
AND situa NOT IN (1, 5)
|
AND situa NOT IN (1, 5)
|
||||||
GROUP BY id_cliente
|
GROUP BY id_cliente
|
||||||
)
|
)
|
||||||
@@ -118,7 +125,7 @@ export class ReportsService {
|
|||||||
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
|
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
|
||||||
FROM vw_clientes c
|
FROM vw_clientes c
|
||||||
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
||||||
WHERE c.cod_vendedor = ${codVendedor}
|
WHERE ${vendCond('c.cod_vendedor')}
|
||||||
AND c.ativo = 1
|
AND c.ativo = 1
|
||||||
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
|
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
|
||||||
`);
|
`);
|
||||||
@@ -176,6 +183,13 @@ export class ReportsService {
|
|||||||
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
|
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
|
||||||
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
|
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
|
||||||
|
|
||||||
|
// Rep vê os próprios pedidos; supervisor vê os da equipe
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||||
|
const vendCond = team
|
||||||
|
? `p.cod_vendedor IN (${team.join(',')})`
|
||||||
|
: `p.cod_vendedor = ${codVendedor}`;
|
||||||
|
|
||||||
interface AbcRow {
|
interface AbcRow {
|
||||||
cod_produto: string | null;
|
cod_produto: string | null;
|
||||||
desc_produto: string | null;
|
desc_produto: string | null;
|
||||||
@@ -196,7 +210,7 @@ export class ReportsService {
|
|||||||
FROM sar.pedido_itens pi
|
FROM sar.pedido_itens pi
|
||||||
JOIN sar.pedidos p ON p.id = pi.id_pedido
|
JOIN sar.pedidos p ON p.id = pi.id_pedido
|
||||||
WHERE p.id_empresa = ${idEmpresa}
|
WHERE p.id_empresa = ${idEmpresa}
|
||||||
AND p.cod_vendedor = ${codVendedor}
|
AND ${vendCond}
|
||||||
AND p.situa NOT IN (0, 3)
|
AND p.situa NOT IN (0, 3)
|
||||||
${mesFilter}
|
${mesFilter}
|
||||||
${anoFilter}
|
${anoFilter}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { Controller, Get, Post, Patch, Param, ParseIntPipe, Body } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { SacService } from './sac.service';
|
import { SacService } from './sac.service';
|
||||||
|
|
||||||
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
||||||
@@ -39,25 +50,42 @@ export class SacRepController {
|
|||||||
|
|
||||||
@Controller('ger/chamados')
|
@Controller('ger/chamados')
|
||||||
export class SacGerController {
|
export class SacGerController {
|
||||||
constructor(private readonly sac: SacService) {}
|
constructor(
|
||||||
|
private readonly sac: SacService,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: visão da empresa toda (detalhe sem checagem de dono) — só gestores.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Apenas supervisores e gerentes podem acessar os chamados da empresa',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
listar() {
|
listar() {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.listarEmpresa();
|
return this.sac.listarEmpresa();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detalhe(@Param('id', ParseIntPipe) id: number) {
|
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.detalhe(id, 'empresa');
|
return this.sac.detalhe(id, 'empresa');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/mensagens')
|
@Post(':id/mensagens')
|
||||||
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/resolver')
|
@Patch(':id/resolver')
|
||||||
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
||||||
|
this.assertGestor();
|
||||||
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,24 @@ export class SacService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Códigos da equipe do supervisor (cod_supervisor em vw_representantes),
|
||||||
|
// incluindo o próprio. Retorna null para gerente/admin (sem filtro).
|
||||||
|
private async teamCodes(): Promise<number[] | null> {
|
||||||
|
const role = (this.cls.get('role') as string | undefined) ?? 'rep';
|
||||||
|
if (role !== 'supervisor') return null;
|
||||||
|
const { prisma, codVendedor } = this.ctx();
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codVendedor}`,
|
||||||
|
);
|
||||||
|
return [...new Set([codVendedor, ...rows.map((r) => Number(r.codigo))])];
|
||||||
|
}
|
||||||
|
|
||||||
async listarEmpresa() {
|
async listarEmpresa() {
|
||||||
const { prisma, idEmpresa } = this.ctx();
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
// Supervisor vê só os chamados da sua equipe; gerente/admin veem a empresa toda
|
||||||
|
const team = await this.teamCodes();
|
||||||
const all = (await prisma.chamado.findMany({
|
const all = (await prisma.chamado.findMany({
|
||||||
where: { idEmpresa },
|
where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||||
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||||
@@ -90,6 +104,10 @@ export class SacService {
|
|||||||
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
|
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
|
||||||
if (!chamado) throw new NotFoundException('Chamado não encontrado');
|
if (!chamado) throw new NotFoundException('Chamado não encontrado');
|
||||||
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
|
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
if (role === 'empresa') {
|
||||||
|
const team = await this.teamCodes();
|
||||||
|
if (team && !team.includes(chamado.codVendedor)) throw new ForbiddenException();
|
||||||
|
}
|
||||||
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||||
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
|
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Códigos de vendedor da equipe de um supervisor (vw_representantes.cod_supervisor
|
||||||
|
// aponta para o código do supervisor), incluindo o próprio supervisor. Usado para
|
||||||
|
// escopar as telas do supervisor à sua equipe — gerente/admin veem a empresa toda.
|
||||||
|
// codSupervisor vem do JWT (parseInt) — interpolação segura.
|
||||||
|
export async function getTeamCodes(prisma: PrismaClient, codSupervisor: number): Promise<number[]> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }[]>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codSupervisor}`,
|
||||||
|
);
|
||||||
|
const codes = new Set(rows.map((r) => Number(r.codigo)));
|
||||||
|
codes.add(codSupervisor);
|
||||||
|
return [...codes];
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ const CACHEABLE_API = [
|
|||||||
|
|
||||||
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
globalThis.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
@@ -102,10 +102,10 @@ function offlineResponse() {
|
|||||||
|
|
||||||
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
globalThis.addEventListener('push', (event) => {
|
||||||
const data = event.data?.json() ?? {};
|
const data = event.data?.json() ?? {};
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(data.title ?? 'SAR', {
|
globalThis.registration.showNotification(data.title ?? 'SAR', {
|
||||||
body: data.body ?? '',
|
body: data.body ?? '',
|
||||||
icon: '/sar-icon.png',
|
icon: '/sar-icon.png',
|
||||||
badge: '/sar-icon.png',
|
badge: '/sar-icon.png',
|
||||||
@@ -114,7 +114,7 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
globalThis.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const url = event.notification.data?.url;
|
const url = event.notification.data?.url;
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Flex,
|
Flex,
|
||||||
|
InputNumber,
|
||||||
Progress,
|
Progress,
|
||||||
Row,
|
Row,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -23,15 +24,40 @@ import {
|
|||||||
faAddressCard,
|
faAddressCard,
|
||||||
faBullseye,
|
faBullseye,
|
||||||
faArrowTrendUp,
|
faArrowTrendUp,
|
||||||
|
faCalendarDay,
|
||||||
faLayerGroup,
|
faLayerGroup,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Chart } from 'react-chartjs-2';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { RankingRep, PositivacaoRep, 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';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
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' });
|
||||||
}
|
}
|
||||||
@@ -185,6 +211,127 @@ const rankingColumns: TableColumnsType<RankingRep> = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function PositivacaoDiariaCard({
|
||||||
|
dados,
|
||||||
|
periodo,
|
||||||
|
periodoLabel,
|
||||||
|
isMesAtual,
|
||||||
|
}: {
|
||||||
|
dados: PositivacaoDia[];
|
||||||
|
periodo: Dayjs;
|
||||||
|
periodoLabel: string;
|
||||||
|
isMesAtual: boolean;
|
||||||
|
}) {
|
||||||
|
const [meta, setMeta] = useState<number>(() => {
|
||||||
|
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
|
||||||
|
return Number.isFinite(saved) && saved > 0 ? saved : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function changeMeta(v: number | null) {
|
||||||
|
const val = v ?? 0;
|
||||||
|
setMeta(val);
|
||||||
|
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 porDia = new Map(dados.map((d) => [dayjs(d.dia).date(), d.clientes]));
|
||||||
|
const labels = Array.from({ length: ultimoDia }, (_, i) => String(i + 1));
|
||||||
|
const valores = labels.map((d) => porDia.get(Number(d)) ?? 0);
|
||||||
|
const diasComMeta = meta > 0 ? valores.filter((v) => v >= meta).length : 0;
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Clientes positivados',
|
||||||
|
data: valores,
|
||||||
|
backgroundColor: 'rgba(0, 74, 153, 0.8)',
|
||||||
|
borderRadius: 4,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
...(meta > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: `Meta (${meta}/dia)`,
|
||||||
|
data: labels.map(() => meta),
|
||||||
|
borderColor: '#f5222d',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 4],
|
||||||
|
pointRadius: 0,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: (items: { label?: string }[]) => `Dia ${items[0]?.label ?? ''}`,
|
||||||
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||||
|
const val = ctx.parsed.y;
|
||||||
|
if (val == null) return '';
|
||||||
|
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { precision: 0, font: { size: 10 } },
|
||||||
|
grid: { color: '#f0f0f0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faCalendarDay} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Positivação Diária de Clientes — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
{meta > 0 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Meta/dia:
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
size="small"
|
||||||
|
value={meta > 0 ? meta : undefined}
|
||||||
|
onChange={changeMeta}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ height: 280 }}>
|
||||||
|
<Chart type="bar" data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GerPainel() {
|
export function GerPainel() {
|
||||||
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
|
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
|
||||||
const mes = periodo.month() + 1;
|
const mes = periodo.month() + 1;
|
||||||
@@ -223,6 +370,7 @@ export function GerPainel() {
|
|||||||
metasPorGrupo,
|
metasPorGrupo,
|
||||||
rankingReps,
|
rankingReps,
|
||||||
positivacaoReps,
|
positivacaoReps,
|
||||||
|
positivacaoDiaria,
|
||||||
syncedAt,
|
syncedAt,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@@ -478,6 +626,14 @@ export function GerPainel() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Positivação Diária */}
|
||||||
|
<PositivacaoDiariaCard
|
||||||
|
dados={positivacaoDiaria}
|
||||||
|
periodo={periodo}
|
||||||
|
periodoLabel={periodoLabel}
|
||||||
|
isMesAtual={isMesAtual}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Positivação por Representante */}
|
{/* Positivação por Representante */}
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -22,7 +23,13 @@ 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 { AlcadaDescontoItem, Promocao } from '@sar/api-interface';
|
import type {
|
||||||
|
AlcadaDescontoItem,
|
||||||
|
Promocao,
|
||||||
|
RegraDesconto,
|
||||||
|
RepStats,
|
||||||
|
GrupoProdutoItem,
|
||||||
|
} from '@sar/api-interface';
|
||||||
import {
|
import {
|
||||||
usePoliticasDescontos,
|
usePoliticasDescontos,
|
||||||
usePromocoes,
|
usePromocoes,
|
||||||
@@ -30,6 +37,12 @@ import {
|
|||||||
useCreatePromocao,
|
useCreatePromocao,
|
||||||
useUpdatePromocao,
|
useUpdatePromocao,
|
||||||
useDeletePromocao,
|
useDeletePromocao,
|
||||||
|
useEquipe,
|
||||||
|
useRegrasDesconto,
|
||||||
|
useGruposProduto,
|
||||||
|
useCreateRegraDesconto,
|
||||||
|
useUpdateRegraDesconto,
|
||||||
|
useDeleteRegraDesconto,
|
||||||
} from '../../lib/queries/gerente';
|
} from '../../lib/queries/gerente';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@@ -49,11 +62,15 @@ function TabDescontos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit(row: AlcadaDescontoItem) {
|
async function saveEdit(row: AlcadaDescontoItem) {
|
||||||
await upsert.mutateAsync({
|
try {
|
||||||
codVendedor: row.codVendedor,
|
await upsert.mutateAsync({
|
||||||
codGrupo: row.codGrupo,
|
codVendedor: row.codVendedor,
|
||||||
limitePerc: editValue,
|
codGrupo: row.codGrupo,
|
||||||
});
|
limitePerc: editValue,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
setEditingKey(null);
|
setEditingKey(null);
|
||||||
void msg.success('Limite atualizado');
|
void msg.success('Limite atualizado');
|
||||||
}
|
}
|
||||||
@@ -197,18 +214,26 @@ function TabPromocoes() {
|
|||||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
dataFim: fim.format('YYYY-MM-DD'),
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
};
|
};
|
||||||
if (editTarget) {
|
try {
|
||||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
if (editTarget) {
|
||||||
void msg.success('Promocao atualizada');
|
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||||
} else {
|
void msg.success('Promocao atualizada');
|
||||||
await createMutation.mutateAsync(body);
|
} else {
|
||||||
void msg.success('Promocao criada');
|
await createMutation.mutateAsync(body);
|
||||||
|
void msg.success('Promocao criada');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
await deleteMutation.mutateAsync(id);
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
void msg.success('Promocao removida');
|
void msg.success('Promocao removida');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +383,294 @@ function TabPromocoes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tab Regras de Desconto ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RegraForm {
|
||||||
|
descricao: string;
|
||||||
|
codVendedor?: number;
|
||||||
|
codGrupo?: number;
|
||||||
|
codSubgrupo?: number;
|
||||||
|
descPct: number;
|
||||||
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabRegras() {
|
||||||
|
const { data, isLoading } = useRegrasDesconto();
|
||||||
|
const { data: equipe } = useEquipe();
|
||||||
|
const { data: gruposData } = useGruposProduto();
|
||||||
|
const createMutation = useCreateRegraDesconto();
|
||||||
|
const updateMutation = useUpdateRegraDesconto();
|
||||||
|
const deleteMutation = useDeleteRegraDesconto();
|
||||||
|
const [form] = Form.useForm<RegraForm>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
||||||
|
|
||||||
|
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
||||||
|
value: r.codVendedor,
|
||||||
|
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
||||||
|
const grupoMap = new Map<number, { value: number; label: string }>();
|
||||||
|
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
||||||
|
for (const g of grupoRows) {
|
||||||
|
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
||||||
|
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
g.codSubgrupo != null &&
|
||||||
|
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
|
||||||
|
!subgrupoMap.has(g.codSubgrupo)
|
||||||
|
) {
|
||||||
|
subgrupoMap.set(g.codSubgrupo, {
|
||||||
|
value: g.codSubgrupo,
|
||||||
|
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const grupoOptions = [...grupoMap.values()];
|
||||||
|
const subgrupoOptions = [...subgrupoMap.values()];
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditTarget(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r: RegraDesconto) {
|
||||||
|
setEditTarget(r);
|
||||||
|
form.setFieldsValue({
|
||||||
|
descricao: r.descricao,
|
||||||
|
codVendedor: r.codVendedor ?? undefined,
|
||||||
|
codGrupo: r.codGrupo ?? undefined,
|
||||||
|
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||||
|
descPct: r.descPct,
|
||||||
|
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(values: RegraForm) {
|
||||||
|
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 {
|
||||||
|
if (editTarget) {
|
||||||
|
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||||
|
void msg.success('Regra atualizada');
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(body);
|
||||||
|
void msg.success('Regra criada');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
void msg.success('Regra removida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const columns: TableColumnsType<RegraDesconto> = [
|
||||||
|
{
|
||||||
|
title: 'Descricao',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
width: 180,
|
||||||
|
render: (_: unknown, row: RegraDesconto) =>
|
||||||
|
row.codVendedor == null ? (
|
||||||
|
<Tag>Todos</Tag>
|
||||||
|
) : (
|
||||||
|
<Text>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Grupo / Subgrupo',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => {
|
||||||
|
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||||
|
const partes = [
|
||||||
|
row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null,
|
||||||
|
row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desconto',
|
||||||
|
dataIndex: 'descPct',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vigencia',
|
||||||
|
width: 200,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => {
|
||||||
|
if (!row.ativa) return <Tag>Inativa</Tag>;
|
||||||
|
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
|
||||||
|
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
|
||||||
|
return <Tag color="green">Ativa</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FontAwesomeIcon icon={faPen} />}
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="Remover regra?"
|
||||||
|
okText="Sim"
|
||||||
|
cancelText="Nao"
|
||||||
|
onConfirm={() => void handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{msgCtx}
|
||||||
|
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
|
||||||
|
Nova Regra
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Table<RegraDesconto>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.regras}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
o desconto automaticamente nos pedidos durante a vigencia.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editTarget ? 'Editar Regra de Desconto' : 'Nova Regra de Desconto'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||||
|
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="descricao"
|
||||||
|
label="Descricao"
|
||||||
|
rules={[{ required: true, message: 'Informe a descricao' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex.: Campanha linha inverno" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="codVendedor" label="Representante">
|
||||||
|
<Select
|
||||||
|
options={repOptions}
|
||||||
|
placeholder="Todos os representantes"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Flex gap={16}>
|
||||||
|
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
|
options={grupoOptions}
|
||||||
|
placeholder="Todos os grupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
|
options={subgrupoOptions}
|
||||||
|
placeholder="Todos os subgrupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="descPct"
|
||||||
|
label="Desconto (%)"
|
||||||
|
rules={[{ required: true, message: 'Informe o desconto' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="periodo"
|
||||||
|
label="Periodo de vigencia"
|
||||||
|
rules={[{ required: true, message: 'Informe o periodo' }]}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={['Inicio', 'Fim']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
|
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function PoliticasPage() {
|
export function PoliticasPage() {
|
||||||
@@ -372,6 +685,11 @@ export function PoliticasPage() {
|
|||||||
label: 'Promocoes',
|
label: 'Promocoes',
|
||||||
children: <TabPromocoes />,
|
children: <TabPromocoes />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'regras',
|
||||||
|
label: 'Regras de Desconto',
|
||||||
|
children: <TabRegras />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export function CarteirePage() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
title="Erro ao carregar carteira"
|
message="Erro ao carregar carteira"
|
||||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
import { EyeOutlined } from '@ant-design/icons';
|
import { EyeOutlined } from '@ant-design/icons';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import type { ProdutoSummary } from '@sar/api-interface';
|
import type { ProdutoSummary } from '@sar/api-interface';
|
||||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||||
|
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||||
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
@@ -16,7 +18,10 @@ function fmtPrice(v: string | null | undefined): string {
|
|||||||
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoSummary> {
|
function buildColumns(
|
||||||
|
onDetail: (id: number) => void,
|
||||||
|
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
|
||||||
|
): TableColumnsType<ProdutoSummary> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: 'Código',
|
title: 'Código',
|
||||||
@@ -29,7 +34,18 @@ function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoS
|
|||||||
dataIndex: 'descricao',
|
dataIndex: 'descricao',
|
||||||
render: (v: string, row: ProdutoSummary) => (
|
render: (v: string, row: ProdutoSummary) => (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.trim()}
|
||||||
|
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||||
|
</div>
|
||||||
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
|
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -101,8 +117,9 @@ export function CatalogPage() {
|
|||||||
|
|
||||||
const { data: pautas, isLoading: pautasLoading } = usePautas();
|
const { data: pautas, isLoading: pautasLoading } = usePautas();
|
||||||
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
|
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
|
||||||
const columns = buildColumns((id) => setSelectedIdErp(id));
|
const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||||
@@ -32,6 +30,8 @@ import {
|
|||||||
} from '../../lib/queries/clients';
|
} from '../../lib/queries/clients';
|
||||||
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const ACTIVITY_COLOR: Record<string, string> = {
|
const ACTIVITY_COLOR: Record<string, string> = {
|
||||||
|
|||||||
@@ -1087,7 +1087,7 @@ export function ClientsPage() {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const r = [...rows];
|
const r = [...rows];
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ function OportModal({
|
|||||||
observacoes: values.observacoes ?? null,
|
observacoes: values.observacoes ?? null,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -479,7 +481,7 @@ export function FunilPage() {
|
|||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
title="Erro ao carregar funil"
|
message="Erro ao carregar funil"
|
||||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
style={{ marginBottom: 16, flexShrink: 0 }}
|
style={{ marginBottom: 16, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -129,7 +129,12 @@ export function NewClientPage() {
|
|||||||
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await createMutation.mutateAsync(payload);
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.sincronizado && result.erroSync) {
|
if (!result.sincronizado && result.erroSync) {
|
||||||
setSyncError(result.erroSync);
|
setSyncError(result.erroSync);
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ 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 { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
||||||
|
import { useRegrasVigentes } from '../../lib/queries/politicas';
|
||||||
|
import { useCondicoesComerciais } from '../../lib/condicoes-comerciais';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -193,6 +197,7 @@ function ProductSearch({
|
|||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 25 });
|
const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 25 });
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
|
||||||
const options = (data?.data ?? []).map((p) => ({
|
const options = (data?.data ?? []).map((p) => ({
|
||||||
value: String(p.idErp),
|
value: String(p.idErp),
|
||||||
@@ -208,8 +213,19 @@ function ProductSearch({
|
|||||||
<Space size={6} align="start" style={{ flex: 1, minWidth: 0 }}>
|
<Space size={6} align="start" style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Tag style={{ margin: 0, fontSize: 11, flexShrink: 0 }}>{p.codigo.trim()}</Tag>
|
<Tag style={{ margin: 0, fontSize: 11, flexShrink: 0 }}>{p.codigo.trim()}</Tag>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ color: '#1F2937', fontWeight: 500, lineHeight: 1.3 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#1F2937',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{p.descricao.trim()}
|
{p.descricao.trim()}
|
||||||
|
<CondicaoTag conds={condicoesDe(p)} compact />
|
||||||
</div>
|
</div>
|
||||||
{p.grupo && (
|
{p.grupo && (
|
||||||
<div style={{ fontSize: 11, color: '#94A3B8', lineHeight: 1.2 }}>
|
<div style={{ fontSize: 11, color: '#94A3B8', lineHeight: 1.2 }}>
|
||||||
@@ -335,6 +351,7 @@ function CatalogBrowserModal({
|
|||||||
page,
|
page,
|
||||||
limit: MODAL_LIMIT,
|
limit: MODAL_LIMIT,
|
||||||
});
|
});
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
|
||||||
const cartMap = new Map(cart.map((it) => [it.idProduto, it.qtd]));
|
const cartMap = new Map(cart.map((it) => [it.idProduto, it.qtd]));
|
||||||
|
|
||||||
@@ -353,7 +370,10 @@ function CatalogBrowserModal({
|
|||||||
title: 'Produto',
|
title: 'Produto',
|
||||||
render: (_: unknown, row: ProdutoSummary) => (
|
render: (_: unknown, row: ProdutoSummary) => (
|
||||||
<div>
|
<div>
|
||||||
<Text style={{ fontWeight: 500 }}>{row.descricao.trim()}</Text>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<Text style={{ fontWeight: 500 }}>{row.descricao.trim()}</Text>
|
||||||
|
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||||
|
</div>
|
||||||
{row.grupo && (
|
{row.grupo && (
|
||||||
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
||||||
{row.grupo.trim()}
|
{row.grupo.trim()}
|
||||||
@@ -460,7 +480,15 @@ function CatalogBrowserModal({
|
|||||||
title: 'Produto',
|
title: 'Produto',
|
||||||
render: (_: unknown, row: ProdutoSummary) => (
|
render: (_: unknown, row: ProdutoSummary) => (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 2,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px' }}>{row.codigo.trim()}</Tag>
|
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px' }}>{row.codigo.trim()}</Tag>
|
||||||
<div
|
<div
|
||||||
title={stockTitle(row.qtdEstoque)}
|
title={stockTitle(row.qtdEstoque)}
|
||||||
@@ -472,6 +500,7 @@ function CatalogBrowserModal({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||||
</div>
|
</div>
|
||||||
<Text style={{ fontWeight: 500, fontSize: 13 }}>{row.descricao.trim()}</Text>
|
<Text style={{ fontWeight: 500, fontSize: 13 }}>{row.descricao.trim()}</Text>
|
||||||
{row.grupo && (
|
{row.grupo && (
|
||||||
@@ -1114,7 +1143,7 @@ function OrderItemsTable({
|
|||||||
emptyText: (
|
emptyText: (
|
||||||
<Empty
|
<Empty
|
||||||
image={<ShoppingCartOutlined style={{ fontSize: 48, color: '#D9E2EC' }} />}
|
image={<ShoppingCartOutlined style={{ fontSize: 48, color: '#D9E2EC' }} />}
|
||||||
imageStyle={{ height: 60 }}
|
styles={{ image: { height: 60 } }}
|
||||||
description={
|
description={
|
||||||
<Space orientation="vertical" size={2}>
|
<Space orientation="vertical" size={2}>
|
||||||
<Text type="secondary">Nenhum produto adicionado ao pedido ainda.</Text>
|
<Text type="secondary">Nenhum produto adicionado ao pedido ainda.</Text>
|
||||||
@@ -1296,6 +1325,23 @@ export function NewOrderPage() {
|
|||||||
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 ──
|
||||||
const addToCart = (p: ProdutoSummary, qty?: number) => {
|
const addToCart = (p: ProdutoSummary, qty?: number) => {
|
||||||
const lote = p.loteMulVenda ?? 1;
|
const lote = p.loteMulVenda ?? 1;
|
||||||
@@ -1317,7 +1363,7 @@ export function NewOrderPage() {
|
|||||||
unidade: p.unidade ?? '',
|
unidade: p.unidade ?? '',
|
||||||
qtd: finalQty,
|
qtd: finalQty,
|
||||||
precoUnitario: Number(p.vlPreco1),
|
precoUnitario: Number(p.vlPreco1),
|
||||||
descontoPerc: 0,
|
descontoPerc: regraDescontoPct(p.codGrupo, p.codSubgrupo),
|
||||||
loteMulVenda: p.loteMulVenda,
|
loteMulVenda: p.loteMulVenda,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -1340,7 +1386,8 @@ export function NewOrderPage() {
|
|||||||
unidade: p.unidade ?? '',
|
unidade: p.unidade ?? '',
|
||||||
qtd: qty,
|
qtd: qty,
|
||||||
precoUnitario: p.ultimoPreco,
|
precoUnitario: p.ultimoPreco,
|
||||||
descontoPerc: 0,
|
// TopProduto não traz grupo/subgrupo — aplica só regra sem restrição
|
||||||
|
descontoPerc: regraDescontoPct(null, null),
|
||||||
loteMulVenda: null,
|
loteMulVenda: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import {
|
|||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
@@ -46,6 +44,8 @@ import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
|
|||||||
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@@ -667,7 +667,7 @@ export function OrdersPage() {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
|
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
|
||||||
import { BarcodeOutlined, InboxOutlined, TagsOutlined } from '@ant-design/icons';
|
import {
|
||||||
|
BarcodeOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
PercentageOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import type { PautaPreco } from '@sar/api-interface';
|
import type { PautaPreco } from '@sar/api-interface';
|
||||||
import { useProdutoDetail } from '../../lib/queries/catalog';
|
import { useProdutoDetail } from '../../lib/queries/catalog';
|
||||||
|
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||||
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@@ -168,6 +175,56 @@ function PautaPrecoList({ items }: { items: PautaPreco[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Condições comerciais vigentes ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtData(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split('-');
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CondicoesList({ conds }: { conds: CondicaoComercial[] }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{conds.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: c.tipo === 'promocao' ? '#FFFBE6' : '#F0F5FF',
|
||||||
|
border: `1px solid ${c.tipo === 'promocao' ? '#FFE58F' : '#ADC6FF'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
|
||||||
|
{c.tipo === 'promocao' ? 'Promoção' : 'Condição especial'} · válida até{' '}
|
||||||
|
{fmtData(c.dataFim)}
|
||||||
|
</Text>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
|
||||||
|
{c.descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: c.tipo === 'promocao' ? '#D48806' : '#2F54EB',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.descPct.toLocaleString('pt-BR')}% desc.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Modal principal ──────────────────────────────────────────────────────────
|
// ─── Modal principal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -177,6 +234,8 @@ interface Props {
|
|||||||
|
|
||||||
export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||||
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
|
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
const conds = data ? condicoesDe(data) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -227,6 +286,7 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
|||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<CondicaoTag conds={conds} />
|
||||||
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
|
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
|
||||||
{data.unidade && <Tag>{data.unidade}</Tag>}
|
{data.unidade && <Tag>{data.unidade}</Tag>}
|
||||||
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
|
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
|
||||||
@@ -239,6 +299,13 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
|||||||
<Row gutter={40}>
|
<Row gutter={40}>
|
||||||
{/* Coluna esquerda */}
|
{/* Coluna esquerda */}
|
||||||
<Col xs={24} md={14}>
|
<Col xs={24} md={14}>
|
||||||
|
{/* Condições comerciais vigentes */}
|
||||||
|
{conds.length > 0 && (
|
||||||
|
<Section icon={<PercentageOutlined />} title="Condições Comerciais">
|
||||||
|
<CondicoesList conds={conds} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Classificação */}
|
{/* Classificação */}
|
||||||
<Section icon={<TagsOutlined />} title="Classificação">
|
<Section icon={<TagsOutlined />} title="Classificação">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
import { Chart } from 'react-chartjs-2';
|
import { Chart } from 'react-chartjs-2';
|
||||||
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
|
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
|
||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||||
|
import { useCurrentUser } from '../../lib/queries/auth';
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -47,8 +49,6 @@ ChartJS.register(
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
Legend,
|
Legend,
|
||||||
);
|
);
|
||||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
|
||||||
import { useCurrentUser } from '../../lib/queries/auth';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
27
apps/web/src/components/CondicaoTag.tsx
Normal file
27
apps/web/src/components/CondicaoTag.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Tag } from 'antd';
|
||||||
|
import type { CondicaoComercial } from '../lib/condicoes-comerciais';
|
||||||
|
|
||||||
|
// Selo que sinaliza produto com proposta comercial diferenciada (promoção do
|
||||||
|
// gerente ou regra de desconto vigente). O detalhamento fica no modal do produto.
|
||||||
|
export function CondicaoTag({ conds, compact }: { conds: CondicaoComercial[]; compact?: boolean }) {
|
||||||
|
if (conds.length === 0) return null;
|
||||||
|
const temPromo = conds.some((c) => c.tipo === 'promocao');
|
||||||
|
const temRegra = conds.some((c) => c.tipo === 'regra');
|
||||||
|
const style = compact
|
||||||
|
? { fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '16px' }
|
||||||
|
: { fontSize: 11, margin: 0 };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{temPromo && (
|
||||||
|
<Tag color="gold" style={style}>
|
||||||
|
Promoção
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{temRegra && (
|
||||||
|
<Tag color="geekblue" style={style}>
|
||||||
|
Cond. especial
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,7 +94,12 @@ export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: P
|
|||||||
anotacoes: values.anotacoes || undefined,
|
anotacoes: values.anotacoes || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await createMutation.mutateAsync(payload);
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.sincronizado && result.erroSync) {
|
if (!result.sincronizado && result.erroSync) {
|
||||||
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
|
|||||||
|
|
||||||
type DevUser = { key: string; userId: string; role: string; label: string };
|
type DevUser = { key: string; userId: string; role: string; label: string };
|
||||||
|
|
||||||
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
|
// userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
|
||||||
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
|
// Usuários provisórios para testar as telas por papel enquanto o cadastro de
|
||||||
|
// usuários não existe: Pavei (carteira própria), Sidnei (equipe via
|
||||||
|
// vw_representantes.cod_supervisor = 191), Lucas (empresa toda).
|
||||||
const DEV_USERS: DevUser[] = [
|
const DEV_USERS: DevUser[] = [
|
||||||
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
|
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
|
||||||
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
|
{ key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
|
||||||
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
|
{ key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
||||||
@@ -27,7 +29,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
|||||||
try {
|
try {
|
||||||
const raw = await apiFetch('/auth/dev/token', {
|
const raw = await apiFetch('/auth/dev/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { userId: user.userId, idEmpresa: 1, role: user.role },
|
body: { userId: user.userId, role: user.role },
|
||||||
});
|
});
|
||||||
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
||||||
authStore.set(accessToken);
|
authStore.set(accessToken);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { Alert, Button, Flex, Grid, Tooltip } from 'antd';
|
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
|
||||||
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Topbar } from './Topbar';
|
import { Topbar } from './Topbar';
|
||||||
@@ -7,6 +7,7 @@ import { Sidebar } from './Sidebar';
|
|||||||
import { BottomNav } from './BottomNav';
|
import { BottomNav } from './BottomNav';
|
||||||
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||||
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||||
|
import { registerMessageApi } from '../../lib/feedback';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@@ -18,10 +19,16 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isOnline = useNetworkStatus();
|
const isOnline = useNetworkStatus();
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
|
const { message } = App.useApp();
|
||||||
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
||||||
const isDesktop = !!screens.lg;
|
const isDesktop = !!screens.lg;
|
||||||
useOfflineSync();
|
useOfflineSync();
|
||||||
|
|
||||||
|
// Disponibiliza o message (com tema) para os caches do TanStack Query
|
||||||
|
useEffect(() => {
|
||||||
|
registerMessageApi(message);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
||||||
{!isOnline && (
|
{!isOnline && (
|
||||||
|
|||||||
64
apps/web/src/lib/condicoes-comerciais.ts
Normal file
64
apps/web/src/lib/condicoes-comerciais.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
||||||
|
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
|
||||||
|
|
||||||
|
// Condição comercial vigente que alcança um produto: promoção (por produto ou
|
||||||
|
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
|
||||||
|
// catálogo do rep que o produto tem proposta diferenciada e detalhar qual é.
|
||||||
|
|
||||||
|
export interface CondicaoComercial {
|
||||||
|
tipo: 'promocao' | 'regra';
|
||||||
|
descricao: string;
|
||||||
|
descPct: number;
|
||||||
|
dataFim: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProdutoRef {
|
||||||
|
codigo: string;
|
||||||
|
codGrupo: number | null;
|
||||||
|
codSubgrupo: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function condicoesDoProduto(
|
||||||
|
p: ProdutoRef,
|
||||||
|
promocoes: PromocaoVigente[] | undefined,
|
||||||
|
regras: RegraVigente[] | undefined,
|
||||||
|
): CondicaoComercial[] {
|
||||||
|
const conds: CondicaoComercial[] = [];
|
||||||
|
|
||||||
|
for (const promo of promocoes ?? []) {
|
||||||
|
const porProduto = promo.codProduto && promo.codProduto.trim() === p.codigo.trim();
|
||||||
|
const porGrupo =
|
||||||
|
promo.grpProd && p.codGrupo != null && promo.grpProd.trim() === String(p.codGrupo);
|
||||||
|
if (porProduto || porGrupo) {
|
||||||
|
conds.push({
|
||||||
|
tipo: 'promocao',
|
||||||
|
descricao: promo.descricao,
|
||||||
|
descPct: promo.descPct,
|
||||||
|
dataFim: promo.dataFim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const regra of regras ?? []) {
|
||||||
|
const grupoOk = regra.codGrupo == null || (p.codGrupo != null && regra.codGrupo === p.codGrupo);
|
||||||
|
const subgrupoOk =
|
||||||
|
regra.codSubgrupo == null || (p.codSubgrupo != null && regra.codSubgrupo === p.codSubgrupo);
|
||||||
|
if (grupoOk && subgrupoOk) {
|
||||||
|
conds.push({
|
||||||
|
tipo: 'regra',
|
||||||
|
descricao: regra.descricao,
|
||||||
|
descPct: regra.descPct,
|
||||||
|
dataFim: regra.dataFim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conds.sort((a, b) => b.descPct - a.descPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook: função de lookup por produto, com promoções e regras vigentes em cache
|
||||||
|
export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[] {
|
||||||
|
const { data: promos } = usePromocoesVigentes();
|
||||||
|
const { data: regras } = useRegrasVigentes();
|
||||||
|
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
||||||
|
}
|
||||||
15
apps/web/src/lib/feedback.ts
Normal file
15
apps/web/src/lib/feedback.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MessageInstance } from 'antd/es/message/interface';
|
||||||
|
|
||||||
|
// Instância do message obtida via App.useApp() (herda tema/contexto), registrada
|
||||||
|
// pelo AppShell no mount. Fora do React (ex.: caches do TanStack Query) usamos
|
||||||
|
// esta referência em vez do message estático do AntD.
|
||||||
|
let messageApi: MessageInstance | null = null;
|
||||||
|
|
||||||
|
export function registerMessageApi(api: MessageInstance): void {
|
||||||
|
messageApi = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key fixa colapsa erros repetidos (várias queries falhando juntas) num toast só.
|
||||||
|
export function notifyError(content: string, key?: string): void {
|
||||||
|
messageApi?.error({ content, key, duration: 5 });
|
||||||
|
}
|
||||||
@@ -19,9 +19,7 @@ export function useCreateOportunidade() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
||||||
apiFetch('/funil', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
|
apiFetch('/funil', { method: 'POST', body: dto }).then((r) => OportunidadeSchema.parse(r)),
|
||||||
OportunidadeSchema.parse(r),
|
|
||||||
),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -30,7 +28,7 @@ export function useUpdateOportunidade() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
||||||
apiFetch(`/funil/${id}`, { method: 'PATCH', body: JSON.stringify(dto) }).then((r) =>
|
apiFetch(`/funil/${id}`, { method: 'PATCH', body: dto }).then((r) =>
|
||||||
OportunidadeSchema.parse(r),
|
OportunidadeSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
EquipeResponseSchema,
|
EquipeResponseSchema,
|
||||||
AlcadaDescontosResponseSchema,
|
AlcadaDescontosResponseSchema,
|
||||||
PromocoesResponseSchema,
|
PromocoesResponseSchema,
|
||||||
|
RegrasDescontoResponseSchema,
|
||||||
|
GruposProdutoResponseSchema,
|
||||||
type ManagerDashboard,
|
type ManagerDashboard,
|
||||||
type EquipeResponse,
|
type EquipeResponse,
|
||||||
type AlcadaDescontosResponse,
|
type AlcadaDescontosResponse,
|
||||||
@@ -11,6 +13,10 @@ import {
|
|||||||
type UpsertDescontoBody,
|
type UpsertDescontoBody,
|
||||||
type CreatePromocaoBody,
|
type CreatePromocaoBody,
|
||||||
type UpdatePromocaoBody,
|
type UpdatePromocaoBody,
|
||||||
|
type RegrasDescontoResponse,
|
||||||
|
type GruposProdutoResponse,
|
||||||
|
type CreateRegraDescontoBody,
|
||||||
|
type UpdateRegraDescontoBody,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ export function useUpsertDesconto() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: UpsertDescontoBody) =>
|
mutationFn: (body: UpsertDescontoBody) =>
|
||||||
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }),
|
apiFetch('/politicas/descontos', { method: 'POST', body }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,7 +82,7 @@ export function useCreatePromocao() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: CreatePromocaoBody) =>
|
mutationFn: (body: CreatePromocaoBody) =>
|
||||||
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }),
|
apiFetch('/politicas/promocoes', { method: 'POST', body }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
@@ -88,7 +94,7 @@ export function useUpdatePromocao() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
|
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
|
||||||
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
@@ -106,3 +112,60 @@ export function useDeletePromocao() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRegrasDesconto() {
|
||||||
|
return useQuery<RegrasDescontoResponse>({
|
||||||
|
queryKey: ['politicas', 'regras'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/regras');
|
||||||
|
return RegrasDescontoResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGruposProduto() {
|
||||||
|
return useQuery<GruposProdutoResponse>({
|
||||||
|
queryKey: ['politicas', 'grupos-produto'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/grupos-produto');
|
||||||
|
return GruposProdutoResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRegraDesconto() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CreateRegraDescontoBody) =>
|
||||||
|
apiFetch('/politicas/regras', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRegraDesconto() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: UpdateRegraDescontoBody }) =>
|
||||||
|
apiFetch(`/politicas/regras/${id}`, { method: 'PATCH', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRegraDesconto() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => apiFetch(`/politicas/regras/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
33
apps/web/src/lib/queries/politicas.ts
Normal file
33
apps/web/src/lib/queries/politicas.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
RegrasVigentesResponseSchema,
|
||||||
|
PromocoesVigentesResponseSchema,
|
||||||
|
type RegrasVigentesResponse,
|
||||||
|
type PromocoesVigentesResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export function useRegrasVigentes() {
|
||||||
|
return useQuery<RegrasVigentesResponse>({
|
||||||
|
queryKey: ['politicas', 'regras-vigentes'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/regras/vigentes');
|
||||||
|
return RegrasVigentesResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
|
||||||
|
export function usePromocoesVigentes() {
|
||||||
|
return useQuery<PromocoesVigentesResponse>({
|
||||||
|
queryKey: ['politicas', 'promocoes-vigentes'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/promocoes/vigentes');
|
||||||
|
return PromocoesVigentesResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,9 +29,7 @@ export function useCreateChamado() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
|
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
|
||||||
apiFetch('/chamados', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
|
apiFetch('/chamados', { method: 'POST', body: dto }).then((r) => ChamadoSchema.parse(r)),
|
||||||
ChamadoSchema.parse(r),
|
|
||||||
),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,8 +38,8 @@ export function useAddMensagemRep(id: number) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||||
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
|
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||||
(r) => ChamadoSchema.parse(r),
|
ChamadoSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['chamados', id] });
|
qc.invalidateQueries({ queryKey: ['chamados', id] });
|
||||||
@@ -79,8 +77,8 @@ export function useAddMensagemEmpresa(id: number) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||||
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
|
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||||
(r) => ChamadoSchema.parse(r),
|
ChamadoSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||||
@@ -93,8 +91,8 @@ export function useResolverChamado(id: number) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
|
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
|
||||||
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: JSON.stringify(dto) }).then(
|
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: dto }).then((r) =>
|
||||||
(r) => ChamadoSchema.parse(r),
|
ChamadoSchema.parse(r),
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||||
|
import { ApiError } from './api-client';
|
||||||
|
import { notifyError } from './feedback';
|
||||||
|
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.problem.detail ?? error.problem.title ?? 'Erro no servidor';
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
return 'Erro inesperado';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueryClient canônico do SAR.
|
* QueryClient canônico do SAR.
|
||||||
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
||||||
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
||||||
|
* Erros nunca são silenciosos: falha de query/mutation sem tratamento local
|
||||||
|
* vira toast — Sandra/Daniel jamais devem "achar que salvou".
|
||||||
*/
|
*/
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
notifyError(`Falha ao carregar dados: ${describeError(error)}`, 'query-error');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error, _variables, _context, mutation) => {
|
||||||
|
if (mutation.options.onError) return; // a tela já dá feedback próprio
|
||||||
|
notifyError(describeError(error), 'mutation-error');
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
notFound,
|
notFound,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
import { Typography } from 'antd';
|
import { Button, Result, Typography } from 'antd';
|
||||||
import { AppShell } from '../components/layout/AppShell';
|
import { AppShell } from '../components/layout/AppShell';
|
||||||
import { RepPainel } from '../cockpits/rep/RepPainel';
|
import { RepPainel } from '../cockpits/rep/RepPainel';
|
||||||
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
||||||
@@ -47,6 +47,23 @@ function HomeRoute() {
|
|||||||
return <RepPainel />;
|
return <RepPainel />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error boundary por rota: uma exceção de render (ex.: ZodError de payload
|
||||||
|
// inesperado) mostra esta tela em vez de derrubar o app inteiro em branco.
|
||||||
|
function RouteErrorPage({ error }: { error: Error }) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Algo deu errado"
|
||||||
|
subTitle={error.message || 'Erro inesperado ao exibir esta tela.'}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
Recarregar
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NotFoundPage() {
|
function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 48, textAlign: 'center' }}>
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||||
@@ -67,6 +84,7 @@ const rootRoute = createRootRoute({
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
),
|
),
|
||||||
notFoundComponent: NotFoundPage,
|
notFoundComponent: NotFoundPage,
|
||||||
|
errorComponent: RouteErrorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
@@ -216,6 +234,7 @@ export const router = createRouter({
|
|||||||
routeTree,
|
routeTree,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultErrorComponent: RouteErrorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default defineConfig(() => ({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
passWithNoTests: true,
|
||||||
reporters: ['default'],
|
reporters: ['default'],
|
||||||
coverage: {
|
coverage: {
|
||||||
reportsDirectory: '../../coverage/apps/web',
|
reportsDirectory: '../../coverage/apps/web',
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
|
|||||||
|
|
||||||
export const DevTokenRequestSchema = z.object({
|
export const DevTokenRequestSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
idEmpresa: z.coerce.number().int().positive(),
|
// Opcional: quando ausente, o backend usa DEV_EMPRESA_ID do .env
|
||||||
|
idEmpresa: z.coerce.number().int().positive().optional(),
|
||||||
role: JwtRoleSchema,
|
role: JwtRoleSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ export const CreateContatoSchema = z.object({
|
|||||||
empresa: z.string().max(100).optional(),
|
empresa: z.string().max(100).optional(),
|
||||||
cargo: z.string().max(100).optional(),
|
cargo: z.string().max(100).optional(),
|
||||||
departamento: z.string().max(100).optional(),
|
departamento: z.string().max(100).optional(),
|
||||||
dtAniversario: z.string().optional(),
|
dtAniversario: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD')
|
||||||
|
.optional(),
|
||||||
telefone: z.string().max(20).optional(),
|
telefone: z.string().max(20).optional(),
|
||||||
ramal: z.string().max(10).optional(),
|
ramal: z.string().max(10).optional(),
|
||||||
celular: z.string().max(20).optional(),
|
celular: z.string().max(20).optional(),
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ export const RankingRepSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RankingRep = z.infer<typeof RankingRepSchema>;
|
export type RankingRep = z.infer<typeof RankingRepSchema>;
|
||||||
|
|
||||||
|
// Clientes distintos positivados por dia do período (YYYY-MM-DD)
|
||||||
|
export const PositivacaoDiaSchema = z.object({
|
||||||
|
dia: z.string(),
|
||||||
|
clientes: z.number().int(),
|
||||||
|
});
|
||||||
|
export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>;
|
||||||
|
|
||||||
export const ManagerDashboardSchema = z.object({
|
export const ManagerDashboardSchema = z.object({
|
||||||
faturamentoMes: z.number(),
|
faturamentoMes: z.number(),
|
||||||
pedidosMes: z.number().int(),
|
pedidosMes: z.number().int(),
|
||||||
@@ -114,6 +121,7 @@ export const ManagerDashboardSchema = z.object({
|
|||||||
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
||||||
rankingReps: z.array(RankingRepSchema),
|
rankingReps: z.array(RankingRepSchema),
|
||||||
positivacaoReps: z.array(PositivacaoRepSchema),
|
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||||
|
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
|
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
||||||
|
|
||||||
|
export const UnsubscribePayloadSchema = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
});
|
||||||
|
export type UnsubscribePayload = z.infer<typeof UnsubscribePayloadSchema>;
|
||||||
|
|
||||||
export const PendingCountResponseSchema = z.object({
|
export const PendingCountResponseSchema = z.object({
|
||||||
count: z.number().int().min(0),
|
count: z.number().int().min(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,12 +101,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
|
|||||||
|
|
||||||
// ─── List query + response ────────────────────────────────────────────────────
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Datas de filtro sempre YYYY-MM-DD — interpoladas em SQL no servidor, o formato
|
||||||
|
// fechado é parte da defesa contra injection, não só validação de UX.
|
||||||
|
export const DateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD');
|
||||||
|
|
||||||
export const PedidoListQuerySchema = z.object({
|
export const PedidoListQuerySchema = z.object({
|
||||||
idCliente: z.coerce.number().int().optional(),
|
idCliente: z.coerce.number().int().optional(),
|
||||||
situa: z.coerce.number().int().optional(),
|
situa: z.coerce.number().int().optional(),
|
||||||
numPedSar: z.string().optional(),
|
numPedSar: z.string().optional(),
|
||||||
from: z.string().optional(),
|
from: DateOnlySchema.optional(),
|
||||||
to: z.string().optional(),
|
to: DateOnlySchema.optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
@@ -184,8 +188,8 @@ export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
|
|||||||
|
|
||||||
export const PedidoErpConsultaQuerySchema = z.object({
|
export const PedidoErpConsultaQuerySchema = z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
from: z.string().optional(),
|
from: DateOnlySchema.optional(),
|
||||||
to: z.string().optional(),
|
to: DateOnlySchema.optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('PingResponseSchema', () => {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'sar-api',
|
service: 'sar-api',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
workspaceId: 'dev-workspace',
|
idEmpresa: 1,
|
||||||
requestId: '550e8400-e29b-41d4-a716-446655440000',
|
requestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
uptimeSeconds: 42,
|
uptimeSeconds: 42,
|
||||||
now: '2026-05-27T12:34:56.000Z',
|
now: '2026-05-27T12:34:56.000Z',
|
||||||
@@ -36,8 +36,8 @@ describe('PingResponseSchema', () => {
|
|||||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejeita workspaceId vazio', () => {
|
it('rejeita idEmpresa não inteiro', () => {
|
||||||
const bad = { ...validPayload, workspaceId: '' };
|
const bad = { ...validPayload, idEmpresa: 1.5 };
|
||||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,3 +53,92 @@ export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().exten
|
|||||||
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 ───────────────────────────────────────────────────────
|
||||||
|
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
|
||||||
|
// produto e/ou representante, com vigência. Campos nulos = "todos".
|
||||||
|
|
||||||
|
export const RegraDescontoSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
codGrupo: z.number().int().nullable(),
|
||||||
|
grupo: z.string().nullable(),
|
||||||
|
codSubgrupo: z.number().int().nullable(),
|
||||||
|
subgrupo: z.string().nullable(),
|
||||||
|
codVendedor: z.number().int().nullable(),
|
||||||
|
nomeVendedor: z.string().nullable(),
|
||||||
|
descPct: z.number(),
|
||||||
|
dataInicio: z.string(),
|
||||||
|
dataFim: z.string(),
|
||||||
|
ativa: z.boolean(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
});
|
||||||
|
export type RegraDesconto = z.infer<typeof RegraDescontoSchema>;
|
||||||
|
|
||||||
|
export const RegrasDescontoResponseSchema = z.object({
|
||||||
|
regras: z.array(RegraDescontoSchema),
|
||||||
|
});
|
||||||
|
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||||
|
|
||||||
|
export const CreateRegraDescontoBodySchema = z.object({
|
||||||
|
descricao: z.string().min(1).max(200),
|
||||||
|
codGrupo: z.number().int().nullable().optional(),
|
||||||
|
codSubgrupo: z.number().int().nullable().optional(),
|
||||||
|
codVendedor: z.number().int().nullable().optional(),
|
||||||
|
descPct: z.number().min(0).max(100),
|
||||||
|
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
});
|
||||||
|
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
|
||||||
|
|
||||||
|
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
|
||||||
|
ativa: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
||||||
|
|
||||||
|
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
||||||
|
export const PromocaoVigenteSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
codProduto: z.string().nullable(),
|
||||||
|
grpProd: z.string().nullable(),
|
||||||
|
descPct: z.number(),
|
||||||
|
dataFim: z.string(),
|
||||||
|
});
|
||||||
|
export type PromocaoVigente = z.infer<typeof PromocaoVigenteSchema>;
|
||||||
|
|
||||||
|
export const PromocoesVigentesResponseSchema = z.object({
|
||||||
|
promocoes: z.array(PromocaoVigenteSchema),
|
||||||
|
});
|
||||||
|
export type PromocoesVigentesResponse = z.infer<typeof PromocoesVigentesResponseSchema>;
|
||||||
|
|
||||||
|
// Regras vigentes para o representante logado — aplicadas na montagem do pedido
|
||||||
|
export const RegraVigenteSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
codGrupo: z.number().int().nullable(),
|
||||||
|
codSubgrupo: z.number().int().nullable(),
|
||||||
|
descPct: z.number(),
|
||||||
|
dataFim: z.string(),
|
||||||
|
});
|
||||||
|
export type RegraVigente = z.infer<typeof RegraVigenteSchema>;
|
||||||
|
|
||||||
|
export const RegrasVigentesResponseSchema = z.object({
|
||||||
|
regras: z.array(RegraVigenteSchema),
|
||||||
|
});
|
||||||
|
export type RegrasVigentesResponse = z.infer<typeof RegrasVigentesResponseSchema>;
|
||||||
|
|
||||||
|
// Grupos/subgrupos de produto (combos do formulário do gerente)
|
||||||
|
export const GrupoProdutoItemSchema = z.object({
|
||||||
|
codGrupo: z.number().int().nullable(),
|
||||||
|
grupo: z.string().nullable(),
|
||||||
|
codSubgrupo: z.number().int().nullable(),
|
||||||
|
subgrupo: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type GrupoProdutoItem = z.infer<typeof GrupoProdutoItemSchema>;
|
||||||
|
|
||||||
|
export const GruposProdutoResponseSchema = z.object({
|
||||||
|
grupos: z.array(GrupoProdutoItemSchema),
|
||||||
|
});
|
||||||
|
export type GruposProdutoResponse = z.infer<typeof GruposProdutoResponseSchema>;
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
|
|||||||
sar.meta_representante,
|
sar.meta_representante,
|
||||||
sar.push_subscription,
|
sar.push_subscription,
|
||||||
sar.promocoes,
|
sar.promocoes,
|
||||||
|
sar.regras_desconto,
|
||||||
sar.clientes_novos,
|
sar.clientes_novos,
|
||||||
sar.contatos_novos
|
sar.contatos_novos
|
||||||
TO ${appRole};
|
TO ${appRole};
|
||||||
|
|||||||
@@ -716,6 +716,26 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
|
|||||||
created_by VARCHAR(100) NOT NULL
|
created_by VARCHAR(100) NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
|
||||||
|
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com
|
||||||
|
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep).
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
descricao VARCHAR(200) NOT NULL,
|
||||||
|
cod_grupo INTEGER,
|
||||||
|
cod_subgrupo INTEGER,
|
||||||
|
cod_vendedor INTEGER,
|
||||||
|
desc_pct NUMERIC(5,2) NOT NULL,
|
||||||
|
data_inicio DATE NOT NULL,
|
||||||
|
data_fim DATE NOT NULL,
|
||||||
|
ativa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- Oportunidades (Funil de Vendas)
|
-- Oportunidades (Funil de Vendas)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
@@ -783,6 +803,8 @@ CREATE INDEX IF NOT EXISTS idx_sar_meta_vend ON sar.meta_representante(cod_v
|
|||||||
CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa);
|
CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sar_promo_empresa ON sar.promocoes(id_empresa);
|
CREATE INDEX IF NOT EXISTS idx_sar_promo_empresa ON sar.promocoes(id_empresa);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sar_promo_vigente ON sar.promocoes(id_empresa, data_fim) WHERE ativa;
|
CREATE INDEX IF NOT EXISTS idx_sar_promo_vigente ON sar.promocoes(id_empresa, data_fim) WHERE ativa;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
|
||||||
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
|
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);
|
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);
|
||||||
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
|
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
|
||||||
@@ -798,6 +820,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.clientes_novos, sar.contatos_novos
|
-- sar.promocoes, sar.regras_desconto, sar.clientes_novos, sar.contatos_novos
|
||||||
-- TO <role>;
|
-- TO <role>;
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user