feat(auth,ger): usuarios dev por papel, escopo do supervisor por equipe e positivacao diaria
- DevLogin com 3 usuarios reais: Pavei (rep 29), Sidnei (supervisor 191) e Lucas (gerente 156); guard deixa de forcar DEV_REP_CODE em dev e usa sub/id_empresa do JWT (fallback para os valores do .env); idEmpresa do token dev opcional, default DEV_EMPRESA_ID - getTeamCodes (vw_representantes.cod_supervisor): supervisor ve so a equipe em pedidos SAR/ERP, detalhe, aprovar/recusar, clientes (lista e carteira), dashboard supervisor, chamados SAC e relatorios carteira/curva ABC; gerente/admin seguem vendo a empresa toda - Painel gerencial: card "Positivacao Diaria de Clientes" (clientes distintos por dia via vw_pedidos_erp) com campo de meta/dia persistido em localStorage, linha de meta tracejada e contador de dias na meta Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,11 +20,13 @@ export class DevAuthController {
|
|||||||
private readonly secret: Uint8Array;
|
private readonly secret: Uint8Array;
|
||||||
private readonly expiresIn: number;
|
private readonly expiresIn: number;
|
||||||
private readonly isProd: boolean;
|
private readonly isProd: boolean;
|
||||||
|
private readonly devEmpresaId: number;
|
||||||
|
|
||||||
constructor(config: ConfigService<Env, true>) {
|
constructor(config: ConfigService<Env, true>) {
|
||||||
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||||
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
||||||
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||||
|
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
@@ -33,7 +35,7 @@ export class DevAuthController {
|
|||||||
if (this.isProd) throw new NotFoundException();
|
if (this.isProd) throw new NotFoundException();
|
||||||
|
|
||||||
const accessToken = await new SignJWT({
|
const accessToken = await new SignJWT({
|
||||||
id_empresa: dto.idEmpresa,
|
id_empresa: dto.idEmpresa ?? this.devEmpresaId,
|
||||||
role: dto.role,
|
role: dto.role,
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
|||||||
@@ -51,10 +51,15 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
||||||
|
|
||||||
// Em dev: força representante fixo (DEV_REP_CODE / DEV_EMPRESA_ID) ignorando o JWT.
|
// Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
|
||||||
// Em prod: usa os valores reais do JWT.
|
// que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
|
||||||
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId;
|
// DEV_EMPRESA_ID apenas se o token não trouxer os campos.
|
||||||
const userId = this.isProd ? payload.sub : this.devRepCode;
|
// Em prod: usa os valores reais do JWT do master-login.
|
||||||
|
const idEmpresa = payload.id_empresa ?? (this.isProd ? undefined : this.devEmpresaId);
|
||||||
|
const userId = payload.sub ?? (this.isProd ? undefined : this.devRepCode);
|
||||||
|
if (idEmpresa == null || userId == null) {
|
||||||
|
throw new UnauthorizedException('token sem sub/id_empresa');
|
||||||
|
}
|
||||||
this.cls.set('idEmpresa', idEmpresa);
|
this.cls.set('idEmpresa', idEmpresa);
|
||||||
this.cls.set('userId', userId);
|
this.cls.set('userId', userId);
|
||||||
this.cls.set('role', payload.role);
|
this.cls.set('role', payload.role);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
TopProduto,
|
TopProduto,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
||||||
const ALERT_DAYS = 30;
|
const ALERT_DAYS = 30;
|
||||||
@@ -114,11 +115,12 @@ 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; gestores passam direto.
|
// 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.
|
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
|
||||||
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
||||||
const role = this.cls.get('role') ?? 'rep';
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
if (role !== 'rep') return;
|
if (role !== 'rep' && role !== 'supervisor') return;
|
||||||
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 userId = this.cls.get('userId');
|
const userId = this.cls.get('userId');
|
||||||
@@ -128,7 +130,10 @@ export class ClientsService {
|
|||||||
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
idCliente,
|
idCliente,
|
||||||
);
|
);
|
||||||
if (!rows[0] || Number(rows[0].cod_vendedor) !== codVendedor) {
|
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`);
|
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,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)}%')`
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
@@ -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)}'
|
||||||
|
|||||||
@@ -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 } } : {}),
|
||||||
@@ -134,7 +139,12 @@ export class OrdersService {
|
|||||||
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
|
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
|
||||||
}
|
}
|
||||||
|
|
||||||
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
|
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}'` : '';
|
||||||
@@ -278,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');
|
||||||
@@ -286,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 },
|
||||||
@@ -468,12 +497,14 @@ export class OrdersService {
|
|||||||
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
||||||
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
|
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
|
||||||
// global — deve caber no limite do grupo do produto (alcada_desconto por
|
// global — deve caber no limite do grupo do produto (alcada_desconto por
|
||||||
// codGrupo; 0 = default), somado a promoção ativa criada pelo gerente para o
|
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
|
||||||
// produto/grupo. Cobre o desconto lançado no item E desconto disfarçado de
|
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o
|
||||||
// preço unitário reduzido. Preço de tabela: pauta do pedido > promocional >
|
// desconto lançado no item E desconto disfarçado de preço unitário reduzido.
|
||||||
// preço base; produto sem referência valida só pelo campo de desconto.
|
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem
|
||||||
|
// referência valida só pelo campo de desconto.
|
||||||
private async assertAlcadaItens(
|
private async assertAlcadaItens(
|
||||||
pedido: {
|
pedido: {
|
||||||
|
codVendedor: number;
|
||||||
descontoPerc: Prisma.Decimal;
|
descontoPerc: Prisma.Decimal;
|
||||||
idPauta: number | null;
|
idPauta: number | null;
|
||||||
itens: {
|
itens: {
|
||||||
@@ -499,13 +530,14 @@ export class OrdersService {
|
|||||||
id_erp: number;
|
id_erp: number;
|
||||||
codigo: string | null;
|
codigo: string | null;
|
||||||
cod_grupo: number | null;
|
cod_grupo: number | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
vl_preco1: string | null;
|
vl_preco1: string | null;
|
||||||
preco_promocional: string | null;
|
preco_promocional: string | null;
|
||||||
preco_pauta: string | null;
|
preco_pauta: string | null;
|
||||||
}
|
}
|
||||||
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
|
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
|
||||||
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
|
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
|
||||||
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo,
|
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo, p.cod_subgrupo,
|
||||||
p.vl_preco1::text, p.preco_promocional::text,
|
p.vl_preco1::text, p.preco_promocional::text,
|
||||||
${
|
${
|
||||||
pedido.idPauta != null
|
pedido.idPauta != null
|
||||||
@@ -530,6 +562,18 @@ export class OrdersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 descGlobal = Number(pedido.descontoPerc);
|
||||||
const problemas: string[] = [];
|
const problemas: string[] = [];
|
||||||
|
|
||||||
@@ -546,8 +590,20 @@ export class OrdersService {
|
|||||||
)
|
)
|
||||||
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||||
|
|
||||||
|
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou
|
||||||
|
// 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 =
|
const limiteItem =
|
||||||
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) + promoPct;
|
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
|
||||||
|
Math.max(promoPct, regraPct);
|
||||||
|
|
||||||
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
||||||
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
||||||
@@ -591,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);
|
||||||
@@ -649,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();
|
||||||
|
|
||||||
@@ -892,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([
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -64,10 +64,24 @@ export class SacService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Códigos da equipe do supervisor (cod_supervisor em vw_representantes),
|
||||||
|
// incluindo o próprio. Retorna null para gerente/admin (sem filtro).
|
||||||
|
private async teamCodes(): Promise<number[] | null> {
|
||||||
|
const role = (this.cls.get('role') as string | undefined) ?? 'rep';
|
||||||
|
if (role !== 'supervisor') return null;
|
||||||
|
const { prisma, codVendedor } = this.ctx();
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codVendedor}`,
|
||||||
|
);
|
||||||
|
return [...new Set([codVendedor, ...rows.map((r) => Number(r.codigo))])];
|
||||||
|
}
|
||||||
|
|
||||||
async listarEmpresa() {
|
async listarEmpresa() {
|
||||||
const { prisma, idEmpresa } = this.ctx();
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
// Supervisor vê só os chamados da sua equipe; gerente/admin veem a empresa toda
|
||||||
|
const team = await this.teamCodes();
|
||||||
const all = (await prisma.chamado.findMany({
|
const all = (await prisma.chamado.findMany({
|
||||||
where: { idEmpresa },
|
where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||||
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||||
@@ -90,6 +104,10 @@ export class SacService {
|
|||||||
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
|
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
|
||||||
if (!chamado) throw new NotFoundException('Chamado não encontrado');
|
if (!chamado) throw new NotFoundException('Chamado não encontrado');
|
||||||
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
|
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
if (role === 'empresa') {
|
||||||
|
const team = await this.teamCodes();
|
||||||
|
if (team && !team.includes(chamado.codVendedor)) throw new ForbiddenException();
|
||||||
|
}
|
||||||
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||||
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
|
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Códigos de vendedor da equipe de um supervisor (vw_representantes.cod_supervisor
|
||||||
|
// aponta para o código do supervisor), incluindo o próprio supervisor. Usado para
|
||||||
|
// escopar as telas do supervisor à sua equipe — gerente/admin veem a empresa toda.
|
||||||
|
// codSupervisor vem do JWT (parseInt) — interpolação segura.
|
||||||
|
export async function getTeamCodes(prisma: PrismaClient, codSupervisor: number): Promise<number[]> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }[]>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codSupervisor}`,
|
||||||
|
);
|
||||||
|
const codes = new Set(rows.map((r) => Number(r.codigo)));
|
||||||
|
codes.add(codSupervisor);
|
||||||
|
return [...codes];
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Flex,
|
Flex,
|
||||||
|
InputNumber,
|
||||||
Progress,
|
Progress,
|
||||||
Row,
|
Row,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -23,15 +24,40 @@ import {
|
|||||||
faAddressCard,
|
faAddressCard,
|
||||||
faBullseye,
|
faBullseye,
|
||||||
faArrowTrendUp,
|
faArrowTrendUp,
|
||||||
|
faCalendarDay,
|
||||||
faLayerGroup,
|
faLayerGroup,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Chart } from 'react-chartjs-2';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { RankingRep, PositivacaoRep, MetaItem } from '@sar/api-interface';
|
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
||||||
import { useManagerDashboard } from '../../lib/queries/gerente';
|
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
|
||||||
|
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
|
||||||
|
|
||||||
function fmt(v: number): string {
|
function fmt(v: number): string {
|
||||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
}
|
}
|
||||||
@@ -185,6 +211,127 @@ const rankingColumns: TableColumnsType<RankingRep> = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function PositivacaoDiariaCard({
|
||||||
|
dados,
|
||||||
|
periodo,
|
||||||
|
periodoLabel,
|
||||||
|
isMesAtual,
|
||||||
|
}: {
|
||||||
|
dados: PositivacaoDia[];
|
||||||
|
periodo: Dayjs;
|
||||||
|
periodoLabel: string;
|
||||||
|
isMesAtual: boolean;
|
||||||
|
}) {
|
||||||
|
const [meta, setMeta] = useState<number>(() => {
|
||||||
|
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
|
||||||
|
return Number.isFinite(saved) && saved > 0 ? saved : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function changeMeta(v: number | null) {
|
||||||
|
const val = v ?? 0;
|
||||||
|
setMeta(val);
|
||||||
|
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores
|
||||||
|
const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
|
||||||
|
const porDia = new Map(dados.map((d) => [dayjs(d.dia).date(), d.clientes]));
|
||||||
|
const labels = Array.from({ length: ultimoDia }, (_, i) => String(i + 1));
|
||||||
|
const valores = labels.map((d) => porDia.get(Number(d)) ?? 0);
|
||||||
|
const diasComMeta = meta > 0 ? valores.filter((v) => v >= meta).length : 0;
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Clientes positivados',
|
||||||
|
data: valores,
|
||||||
|
backgroundColor: 'rgba(0, 74, 153, 0.8)',
|
||||||
|
borderRadius: 4,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
...(meta > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: `Meta (${meta}/dia)`,
|
||||||
|
data: labels.map(() => meta),
|
||||||
|
borderColor: '#f5222d',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 4],
|
||||||
|
pointRadius: 0,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: (items: { label?: string }[]) => `Dia ${items[0]?.label ?? ''}`,
|
||||||
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||||
|
const val = ctx.parsed.y;
|
||||||
|
if (val == null) return '';
|
||||||
|
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { precision: 0, font: { size: 10 } },
|
||||||
|
grid: { color: '#f0f0f0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faCalendarDay} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Positivação Diária de Clientes — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
{meta > 0 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Meta/dia:
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
size="small"
|
||||||
|
value={meta > 0 ? meta : undefined}
|
||||||
|
onChange={changeMeta}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ height: 280 }}>
|
||||||
|
<Chart type="bar" data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GerPainel() {
|
export function GerPainel() {
|
||||||
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
|
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
|
||||||
const mes = periodo.month() + 1;
|
const mes = periodo.month() + 1;
|
||||||
@@ -223,6 +370,7 @@ export function GerPainel() {
|
|||||||
metasPorGrupo,
|
metasPorGrupo,
|
||||||
rankingReps,
|
rankingReps,
|
||||||
positivacaoReps,
|
positivacaoReps,
|
||||||
|
positivacaoDiaria,
|
||||||
syncedAt,
|
syncedAt,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@@ -478,6 +626,14 @@ export function GerPainel() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Positivação Diária */}
|
||||||
|
<PositivacaoDiariaCard
|
||||||
|
dados={positivacaoDiaria}
|
||||||
|
periodo={periodo}
|
||||||
|
periodoLabel={periodoLabel}
|
||||||
|
isMesAtual={isMesAtual}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Positivação por Representante */}
|
{/* Positivação por Representante */}
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user