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:
2026-07-22 17:58:19 +00:00
51 changed files with 1817 additions and 110 deletions

View File

@@ -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;

View File

@@ -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)

View File

@@ -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' })

View File

@@ -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);

View File

@@ -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) => ({

View File

@@ -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,

View File

@@ -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)}'

View File

@@ -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')

View File

@@ -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> {

View File

@@ -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([

View File

@@ -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();
}
} }

View File

@@ -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),
})),
};
}
} }

View File

@@ -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}

View File

@@ -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));
} }
} }

View File

@@ -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 };
} }

View 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];
}

View File

@@ -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) {

View File

@@ -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 &mdash; {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={

View File

@@ -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) {
try {
await upsert.mutateAsync({ await upsert.mutateAsync({
codVendedor: row.codVendedor, codVendedor: row.codVendedor,
codGrupo: row.codGrupo, codGrupo: row.codGrupo,
limitePerc: editValue, 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,6 +214,7 @@ 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'),
}; };
try {
if (editTarget) { if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body }); await updateMutation.mutateAsync({ id: editTarget.id, body });
void msg.success('Promocao atualizada'); void msg.success('Promocao atualizada');
@@ -204,11 +222,18 @@ function TabPromocoes() {
await createMutation.mutateAsync(body); await createMutation.mutateAsync(body);
void msg.success('Promocao criada'); 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) {
try {
await deleteMutation.mutateAsync(id); 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 (

View File

@@ -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 }}
/> />

View File

@@ -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' }}>

View File

@@ -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> = {

View File

@@ -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];

View File

@@ -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 }}
/> />

View File

@@ -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);

View File

@@ -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>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Text style={{ fontWeight: 500 }}>{row.descricao.trim()}</Text> <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,
}, },
]; ];

View File

@@ -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;

View File

@@ -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' }}>

View File

@@ -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;

View 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>
)}
</>
);
}

View File

@@ -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}`);

View File

@@ -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);

View File

@@ -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 && (

View 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);
}

View 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 });
}

View File

@@ -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'] }),

View File

@@ -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'] });
},
});
}

View 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,
});
}

View File

@@ -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] });

View File

@@ -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

View File

@@ -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' {

View File

@@ -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',

View File

@@ -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,
}); });

View File

@@ -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(),

View File

@@ -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>;

View File

@@ -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),
}); });

View File

@@ -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),
}); });

View File

@@ -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();
}); });
}); });

View File

@@ -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>;

View File

@@ -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};

View File

@@ -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>;
-- ============================================================================= -- =============================================================================