diff --git a/apps/api/src/app/auth/dev-auth.controller.ts b/apps/api/src/app/auth/dev-auth.controller.ts index 685a8cc..5cfa594 100644 --- a/apps/api/src/app/auth/dev-auth.controller.ts +++ b/apps/api/src/app/auth/dev-auth.controller.ts @@ -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) { 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' }) diff --git a/apps/api/src/app/auth/jwt-auth.guard.ts b/apps/api/src/app/auth/jwt-auth.guard.ts index 410f07a..9581f99 100644 --- a/apps/api/src/app/auth/jwt-auth.guard.ts +++ b/apps/api/src/app/auth/jwt-auth.guard.ts @@ -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); diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index 0f5af02..8cdefe4 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -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) {} - // 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 { 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)}%')` : ''; diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 07eb657..fdcf0e3 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -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(` 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(` + 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(` 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)}' diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index bf29e17..3933459 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -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 { + 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 { 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(` - 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([ diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts index bda3ce3..e46c9ac 100644 --- a/apps/api/src/app/reports/reports.service.ts +++ b/apps/api/src/app/reports/reports.service.ts @@ -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} diff --git a/apps/api/src/app/sac/sac.service.ts b/apps/api/src/app/sac/sac.service.ts index de654d7..62be66b 100644 --- a/apps/api/src/app/sac/sac.service.ts +++ b/apps/api/src/app/sac/sac.service.ts @@ -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 { + 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 }; } diff --git a/apps/api/src/app/workspace/team-scope.util.ts b/apps/api/src/app/workspace/team-scope.util.ts new file mode 100644 index 0000000..d1da6f2 --- /dev/null +++ b/apps/api/src/app/workspace/team-scope.util.ts @@ -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 { + 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]; +} diff --git a/apps/web/src/cockpits/ger/GerPainel.tsx b/apps/web/src/cockpits/ger/GerPainel.tsx index 7028356..69ce83b 100644 --- a/apps/web/src/cockpits/ger/GerPainel.tsx +++ b/apps/web/src/cockpits/ger/GerPainel.tsx @@ -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 = [ }, ]; +function PositivacaoDiariaCard({ + dados, + periodo, + periodoLabel, + isMesAtual, +}: { + dados: PositivacaoDia[]; + periodo: Dayjs; + periodoLabel: string; + isMesAtual: boolean; +}) { + const [meta, setMeta] = useState(() => { + 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 ( + + + Positivação Diária de Clientes — {periodoLabel} + + } + extra={ + + {meta > 0 && ( + + {diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta + + )} + + Meta/dia: + + 0 ? meta : undefined} + onChange={changeMeta} + placeholder="—" + style={{ width: 80 }} + /> + + } + > +
+ +
+
+ ); +} + export function GerPainel() { const [periodo, setPeriodo] = useState(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() { /> + {/* Positivação Diária */} + + {/* Positivação por Representante */} 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); diff --git a/libs/shared/api-interface/src/lib/auth.contract.ts b/libs/shared/api-interface/src/lib/auth.contract.ts index f27b434..7ed70aa 100644 --- a/libs/shared/api-interface/src/lib/auth.contract.ts +++ b/libs/shared/api-interface/src/lib/auth.contract.ts @@ -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, }); diff --git a/libs/shared/api-interface/src/lib/dashboard.contract.ts b/libs/shared/api-interface/src/lib/dashboard.contract.ts index afc3c59..a3f9d7e 100644 --- a/libs/shared/api-interface/src/lib/dashboard.contract.ts +++ b/libs/shared/api-interface/src/lib/dashboard.contract.ts @@ -104,6 +104,13 @@ export const RankingRepSchema = z.object({ }); export type RankingRep = z.infer; +// 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; + 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;