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:
2026-07-22 17:24:54 +00:00
parent f543b8ac7f
commit 996eff8e65
12 changed files with 361 additions and 32 deletions

View File

@@ -20,11 +20,13 @@ export class DevAuthController {
private readonly secret: Uint8Array;
private readonly expiresIn: number;
private readonly isProd: boolean;
private readonly devEmpresaId: number;
constructor(config: ConfigService<Env, true>) {
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
}
@Post('token')
@@ -33,7 +35,7 @@ export class DevAuthController {
if (this.isProd) throw new NotFoundException();
const accessToken = await new SignJWT({
id_empresa: dto.idEmpresa,
id_empresa: dto.idEmpresa ?? this.devEmpresaId,
role: dto.role,
})
.setProtectedHeader({ alg: 'HS256' })

View File

@@ -51,10 +51,15 @@ export class JwtAuthGuard implements CanActivate {
(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 prod: usa os valores reais do JWT.
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId;
const userId = this.isProd ? payload.sub : this.devRepCode;
// Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
// que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
// DEV_EMPRESA_ID apenas se o token não trouxer os campos.
// 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('userId', userId);
this.cls.set('role', payload.role);

View File

@@ -18,6 +18,7 @@ import type {
TopProduto,
} from '@sar/api-interface';
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.
const ALERT_DAYS = 30;
@@ -114,11 +115,12 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
export class ClientsService {
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.
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'rep') return;
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');
@@ -128,7 +130,10 @@ export class ClientsService {
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
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`);
}
}
@@ -144,8 +149,13 @@ export class ClientsService {
const { q, status, page, limit } = query;
const offset = (page - 1) * limit;
// Rep vê apenas sua carteira (cod_vendedor = seu código)
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
// Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
const vendedorFilter =
role === 'rep'
? `AND c.cod_vendedor = ${codVendedor}`
: role === 'supervisor'
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
: '';
const searchFilter = q
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
: '';

View File

@@ -8,6 +8,7 @@ import type {
PositivacaoRep,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
@@ -413,6 +414,10 @@ export class DashboardService {
total_clientes: string;
clientes_positivados: string;
}
interface PositivacaoDiaRow {
dia: string;
clientes: string;
}
const [
statsRows,
@@ -422,6 +427,7 @@ export class DashboardService {
positivacaoRows,
metaGrupoRows,
realizadoGrupoRows,
positivacaoDiariaRows,
] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
@@ -518,6 +524,17 @@ export class DashboardService {
AND e.dt_pedido <= '${monthEndStr}'
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);
@@ -590,6 +607,10 @@ export class DashboardService {
metasPorGrupo,
rankingReps,
positivacaoReps,
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
dia: r.dia,
clientes: Number(r.clientes),
})),
syncedAt: now.toISOString(),
};
}
@@ -600,9 +621,21 @@ export class DashboardService {
const idEmpresa = this.cls.get('idEmpresa');
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)
const approvalQueue = await prisma.pedido.findMany({
where: { idEmpresa, situa: SITUA_PENDENTE },
where: {
idEmpresa,
situa: SITUA_PENDENTE,
...(team ? { codVendedor: { in: team } } : {}),
},
orderBy: { dtPedido: 'asc' },
take: 50,
});
@@ -626,12 +659,14 @@ export class DashboardService {
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
${teamSqlFilter('cod_vendedor')}
`),
prisma.$queryRawUnsafe<DayRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 5
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.situa != 5
WHERE c.ativo = 1
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.id_cliente, c.cod_vendedor
HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'

View File

@@ -14,6 +14,7 @@ import type {
RecusarPedido,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
import { NotificationsService } from '../notifications/notifications.service';
// 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 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 = {
idEmpresa,
...(role === 'rep' ? { codVendedor } : {}),
...(role === 'supervisor' ? { codVendedor: { in: team } } : {}),
...(idCliente != null ? { idCliente } : {}),
...(situa != null ? { situa } : {}),
...(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');
}
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 toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : '';
@@ -278,6 +288,20 @@ export class OrdersService {
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> {
const prisma = this.cls.get('prisma');
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 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({
where: { id, idEmpresa, ...repFilter },
@@ -468,12 +497,14 @@ export class OrdersService {
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
// global — deve caber no limite do grupo do produto (alcada_desconto por
// codGrupo; 0 = default), somado a promoção ativa criada pelo gerente para o
// produto/grupo. 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.
// 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: {
@@ -499,13 +530,14 @@ export class OrdersService {
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,
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
@@ -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 problemas: string[] = [];
@@ -546,8 +590,20 @@ export class OrdersService {
)
.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) + 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
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
@@ -591,6 +647,7 @@ export class OrdersService {
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
@@ -649,6 +706,7 @@ export class OrdersService {
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
@@ -892,7 +950,12 @@ export class OrdersService {
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 [headerRows, itemRows] = await Promise.all([

View File

@@ -9,6 +9,7 @@ import type {
AbcProduto,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
function d(v: unknown): string {
return v != null ? String(v) : '0';
@@ -77,6 +78,12 @@ export class ReportsService {
const userId = this.cls.get('userId');
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 {
id_cliente: unknown;
nome: string | null;
@@ -102,7 +109,7 @@ export class ReportsService {
COALESCE(SUM(total), 0)::numeric AS faturamento_total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND ${vendCond('cod_vendedor')}
AND situa NOT IN (1, 5)
GROUP BY id_cliente
)
@@ -118,7 +125,7 @@ export class ReportsService {
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
FROM vw_clientes c
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
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 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 {
cod_produto: string | null;
desc_produto: string | null;
@@ -196,7 +210,7 @@ export class ReportsService {
FROM sar.pedido_itens pi
JOIN sar.pedidos p ON p.id = pi.id_pedido
WHERE p.id_empresa = ${idEmpresa}
AND p.cod_vendedor = ${codVendedor}
AND ${vendCond}
AND p.situa NOT IN (0, 3)
${mesFilter}
${anoFilter}

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() {
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({
where: { idEmpresa },
where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) },
orderBy: { updatedAt: 'desc' },
})) as { idCliente: number; status: string; [k: string]: unknown }[];
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
@@ -90,6 +104,10 @@ export class SacService {
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
if (!chamado) throw new NotFoundException('Chamado não encontrado');
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]);
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

@@ -5,6 +5,7 @@ import {
Col,
DatePicker,
Flex,
InputNumber,
Progress,
Row,
Skeleton,
@@ -23,15 +24,40 @@ import {
faAddressCard,
faBullseye,
faArrowTrendUp,
faCalendarDay,
faLayerGroup,
} 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 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';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
const { Title, Text } = Typography;
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
@@ -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() {
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
const mes = periodo.month() + 1;
@@ -223,6 +370,7 @@ export function GerPainel() {
metasPorGrupo,
rankingReps,
positivacaoReps,
positivacaoDiaria,
syncedAt,
} = data;
@@ -478,6 +626,14 @@ export function GerPainel() {
/>
</Card>
{/* Positivação Diária */}
<PositivacaoDiariaCard
dados={positivacaoDiaria}
periodo={periodo}
periodoLabel={periodoLabel}
isMesAtual={isMesAtual}
/>
{/* Positivação por Representante */}
<Card
title={

View File

@@ -9,12 +9,14 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
type DevUser = { key: string; userId: string; role: string; label: string };
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
// userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
// 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[] = [
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
{ key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
{ key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
];
export function DevLogin({ onLogin }: { onLogin: () => void }) {
@@ -27,7 +29,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
try {
const raw = await apiFetch('/auth/dev/token', {
method: 'POST',
body: { userId: user.userId, idEmpresa: 1, role: user.role },
body: { userId: user.userId, role: user.role },
});
const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken);

View File

@@ -9,7 +9,8 @@ const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
export const DevTokenRequestSchema = z.object({
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,
});

View File

@@ -104,6 +104,13 @@ export const RankingRepSchema = z.object({
});
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({
faturamentoMes: z.number(),
pedidosMes: z.number().int(),
@@ -114,6 +121,7 @@ export const ManagerDashboardSchema = z.object({
metasPorGrupo: z.array(MetaItemSchema).default([]),
rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
syncedAt: z.iso.datetime(),
});
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;