diff --git a/apps/api/prisma/migrations/20260723000000_regras_desconto_add_pauta/migration.sql b/apps/api/prisma/migrations/20260723000000_regras_desconto_add_pauta/migration.sql new file mode 100644 index 0000000..72e0a68 --- /dev/null +++ b/apps/api/prisma/migrations/20260723000000_regras_desconto_add_pauta/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable: sar.regras_desconto -- adiciona escopo por pauta de precos. +-- Regra pode ser restrita a uma pauta especifica; id_pauta NULL = qualquer pauta. +-- provision-client.ts roda scripts/sar-erp-schema.sql (idempotente) antes de +-- `prisma migrate deploy`, entao este ALTER precisa ser idempotente tambem. +-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui. +ALTER TABLE sar.regras_desconto ADD COLUMN IF NOT EXISTS id_pauta INTEGER; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5e8730d..b2561b5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -174,7 +174,8 @@ model Promocao { // ─── RegraDesconto (Políticas Comerciais) ──────────────────────────────────── // // Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou -// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos". +// subgrupo de produto, pauta de preços e/ou representante, com vigência. +// Campos nulos = "todos" (id_pauta nulo = qualquer pauta). model RegraDesconto { id Int @id @default(autoincrement()) @@ -183,6 +184,7 @@ model RegraDesconto { codGrupo Int? @map("cod_grupo") codSubgrupo Int? @map("cod_subgrupo") codVendedor Int? @map("cod_vendedor") + idPauta Int? @map("id_pauta") descPct Decimal @db.Decimal(5, 2) @map("desc_pct") dataInicio DateTime @db.Date @map("data_inicio") dataFim DateTime @db.Date @map("data_fim") diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts index 442a299..78df0a2 100644 --- a/apps/api/src/app/dashboard/dashboard.controller.ts +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -45,9 +45,13 @@ export class DashboardController { } @Get('supervisor') - supervisorDashboard(): Promise { + supervisorDashboard( + @Query('codSupervisor') codSupervisor?: string, + ): Promise { this.assertGestor(); - return this.dashboard.supervisorDashboard(); + return this.dashboard.supervisorDashboard( + codSupervisor ? parseInt(codSupervisor, 10) : undefined, + ); } @Get('supervisor/inativos') diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index a66fa8c..599a117 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -729,18 +729,25 @@ export class DashboardService { }); } - async supervisorDashboard(): Promise { + async supervisorDashboard(codSupervisor?: number): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); const idEmpresa = this.cls.get('idEmpresa'); const now = new Date(); - // Supervisor vê só a própria equipe (cod_supervisor em vw_representantes); - // gerente/admin acessando este painel veem a empresa toda. + // Supervisor vê só a própria equipe (cod_supervisor em vw_representantes). + // Gerente/admin: sem codSupervisor veem a empresa toda; com codSupervisor + // (Detalhar da aba Equipe) veem o time daquele supervisor. codSupervisor é + // ignorado para role 'supervisor' — não pode espiar outra equipe. 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; + let teamCode: number | null = null; + if (role === 'supervisor') { + teamCode = userId ? parseInt(userId, 10) : 0; + } else if (codSupervisor && codSupervisor > 0) { + teamCode = codSupervisor; + } + const team = teamCode ? await getTeamCodes(prisma, teamCode) : null; const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : ''); // Pedidos do dia — lê do ERP (situa != 5=Cancelado) diff --git a/apps/api/src/app/equipe/equipe.controller.ts b/apps/api/src/app/equipe/equipe.controller.ts index 339d5fb..3432288 100644 --- a/apps/api/src/app/equipe/equipe.controller.ts +++ b/apps/api/src/app/equipe/equipe.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get } from '@nestjs/common'; -import type { EquipeResponse } from '@sar/api-interface'; +import type { EquipeResponse, SupervisoresResponse } from '@sar/api-interface'; import { EquipeService } from './equipe.service'; @Controller({ path: 'equipe' }) @@ -10,4 +10,9 @@ export class EquipeController { listEquipe(): Promise { return this.equipe.listEquipe(); } + + @Get('supervisores') + listSupervisores(): Promise { + return this.equipe.listSupervisores(); + } } diff --git a/apps/api/src/app/equipe/equipe.service.ts b/apps/api/src/app/equipe/equipe.service.ts index 5bb3f79..cfbe491 100644 --- a/apps/api/src/app/equipe/equipe.service.ts +++ b/apps/api/src/app/equipe/equipe.service.ts @@ -1,6 +1,6 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; -import type { EquipeResponse } from '@sar/api-interface'; +import type { EquipeResponse, SupervisorCard, SupervisoresResponse } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; function matrizEmpresa(idEmpresa: number): number { @@ -110,4 +110,173 @@ export class EquipeService { syncedAt: now.toISOString(), }; } + + // Cards de supervisor da aba Equipe: agrega as métricas do mês por equipe. + // Time de um supervisor = reps cujo cod_supervisor aponta para ele + ele + // próprio (mesma regra de getTeamCodes, que o Detalhe reusa via + // /dashboard/supervisor?codSupervisor=). Agregação feita em JS a partir de + // métricas por rep — as mesmas contas do painel, sem escopo por equipe. + async listSupervisores(): Promise { + const role = this.cls.get('role') ?? 'rep'; + if (role !== 'manager' && role !== 'admin') { + throw new ForbiddenException('Apenas gerentes podem consultar os supervisores'); + } + + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const idEmpresaMatriz = matrizEmpresa(idEmpresa); + + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10); + const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10); + const prevStartStr = new Date(year, month - 2, 1).toISOString().slice(0, 10); + const prevEndStr = new Date(year, month - 1, 0).toISOString().slice(0, 10); + + const [repRows, pedidosRows, prevRows, metaRows, positivacaoRows] = await Promise.all([ + prisma.$queryRawUnsafe< + { cod_vendedor: number; cod_supervisor: number | null; nome: string | null }[] + >(`SELECT codigo AS cod_vendedor, cod_supervisor, nome FROM vw_representantes`), + prisma.$queryRawUnsafe(` + SELECT cod_vendedor, + COUNT(*)::text AS pedidos, + COALESCE(SUM(total), 0)::text AS faturamento + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} + AND situa NOT IN (1, 5) + AND dt_pedido >= '${monthStartStr}' + AND dt_pedido <= '${monthEndStr}' + GROUP BY cod_vendedor + `), + prisma.$queryRawUnsafe<{ cod_vendedor: number; faturamento: string }[]>(` + SELECT cod_vendedor, + COALESCE(SUM(total), 0)::text AS faturamento + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} + AND situa NOT IN (1, 5) + AND dt_pedido >= '${prevStartStr}' + AND dt_pedido <= '${prevEndStr}' + GROUP BY cod_vendedor + `), + prisma.$queryRawUnsafe(` + SELECT cod_vendedor, + SUM(valor)::text AS meta_valor + FROM sar.vw_metas + WHERE id_empresa = ${idEmpresaMatriz} + AND ano = ${year} + AND mes = ${month} + AND TRIM(tipo) = 'GR' + GROUP BY cod_vendedor + `), + prisma.$queryRawUnsafe< + { cod_vendedor: number; total_clientes: string; positivados: string }[] + >(` + SELECT c.cod_vendedor, + COUNT(DISTINCT c.id_cliente)::text AS total_clientes, + COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS positivados + FROM vw_clientes c + LEFT JOIN ( + SELECT DISTINCT id_cliente, cod_vendedor + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} + AND situa NOT IN (1, 5) + AND dt_pedido >= '${monthStartStr}' + AND dt_pedido <= '${monthEndStr}' + ) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor + WHERE c.cod_vendedor > 0 + GROUP BY c.cod_vendedor + `), + ]); + + const pedidosMap = new Map( + pedidosRows.map((r) => [ + Number(r.cod_vendedor), + { pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) }, + ]), + ); + const prevMap = new Map(prevRows.map((r) => [Number(r.cod_vendedor), Number(r.faturamento)])); + const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)])); + const posMap = new Map( + positivacaoRows.map((r) => [ + Number(r.cod_vendedor), + { total: Number(r.total_clientes), positivados: Number(r.positivados) }, + ]), + ); + + // Monta a equipe de cada supervisor: código do supervisor -> códigos dos reps. + // O supervisor entra no próprio time (getTeamCodes inclui o supervisor). + const nomeByCod = new Map(); + const membrosBySup = new Map>(); + for (const r of repRows) { + const cod = Number(r.cod_vendedor); + if (!nomeByCod.has(cod)) nomeByCod.set(cod, r.nome ?? null); + const sup = r.cod_supervisor != null ? Number(r.cod_supervisor) : 0; + if (sup > 0) { + let membros = membrosBySup.get(sup); + if (!membros) { + membros = new Set(); + membrosBySup.set(sup, membros); + } + membros.add(cod); + } + } + + const supervisores: SupervisorCard[] = [...membrosBySup.entries()].map(([sup, membros]) => { + const team = new Set(membros); + team.add(sup); // o próprio supervisor faz parte do time + + let faturamento = 0; + let faturamentoAnterior = 0; + let pedidos = 0; + let meta = 0; + let totalClientes = 0; + let positivados = 0; + let repsAtivos = 0; + for (const cod of team) { + const p = pedidosMap.get(cod); + if (p) { + faturamento += p.faturamento; + pedidos += p.pedidos; + if (p.faturamento > 0) repsAtivos += 1; + } + faturamentoAnterior += prevMap.get(cod) ?? 0; + meta += metaMap.get(cod) ?? 0; + const pos = posMap.get(cod); + if (pos) { + totalClientes += pos.total; + positivados += pos.positivados; + } + } + + return { + codSupervisor: sup, + nomeSupervisor: nomeByCod.get(sup) ?? null, + faturamentoMes: faturamento, + faturamentoMesAnterior: faturamentoAnterior, + variacaoPct: + faturamentoAnterior > 0 + ? Math.round(((faturamento - faturamentoAnterior) / faturamentoAnterior) * 100) + : null, + pedidosMes: pedidos, + ticketMedio: pedidos > 0 ? faturamento / pedidos : 0, + metaTotal: meta, + pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0, + totalClientes, + clientesPositivados: positivados, + pctPositivacao: totalClientes > 0 ? Math.round((positivados / totalClientes) * 100) : 0, + repsTotal: team.size, + repsAtivos, + }; + }); + + // Ranking/disputa: maior faturamento primeiro. + supervisores.sort((a, b) => b.faturamentoMes - a.faturamentoMes); + + return { + supervisores, + syncedAt: now.toISOString(), + }; + } } diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index 9a9bdeb..d517620 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -626,14 +626,16 @@ 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. + // Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou + // pauta, precisa casar com o que a regra especifica. Regra por pauta só + // vale quando o pedido usa aquela pauta (id_pauta nulo = qualquer pauta). 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)), + (r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)) && + (r.idPauta == null || (pedido.idPauta != null && r.idPauta === pedido.idPauta)), ) .reduce((max, r) => Math.max(max, Number(r.descPct)), 0); diff --git a/apps/api/src/app/politicas/politicas.controller.ts b/apps/api/src/app/politicas/politicas.controller.ts index 625529b..1ce105c 100644 --- a/apps/api/src/app/politicas/politicas.controller.ts +++ b/apps/api/src/app/politicas/politicas.controller.ts @@ -11,17 +11,14 @@ import { } from '@nestjs/common'; import { createZodDto } from 'nestjs-zod'; import { - UpsertDescontoBodySchema, CreatePromocaoBodySchema, UpdatePromocaoBodySchema, CreateRegraDescontoBodySchema, UpdateRegraDescontoBodySchema, - type UpsertDescontoBody, type CreatePromocaoBody, type UpdatePromocaoBody, type CreateRegraDescontoBody, type UpdateRegraDescontoBody, - type AlcadaDescontosResponse, type PromocoesResponse, type PromocoesVigentesResponse, type AlcadaRepResponse, @@ -31,7 +28,6 @@ import { } from '@sar/api-interface'; import { PoliticasService } from './politicas.service'; -class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {} @@ -41,17 +37,6 @@ class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) export class PoliticasController { constructor(private readonly politicas: PoliticasService) {} - @Get('descontos') - listDescontos(): Promise { - return this.politicas.listDescontos(); - } - - @Post('descontos') - @HttpCode(200) - upsertDesconto(@Body() body: UpsertDescontoDto): Promise { - return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody); - } - @Get('promocoes') listPromocoes(): Promise { return this.politicas.listPromocoes(); diff --git a/apps/api/src/app/politicas/politicas.service.ts b/apps/api/src/app/politicas/politicas.service.ts index f0a9cb1..cf5d6e2 100644 --- a/apps/api/src/app/politicas/politicas.service.ts +++ b/apps/api/src/app/politicas/politicas.service.ts @@ -1,8 +1,6 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import type { - AlcadaDescontosResponse, - UpsertDescontoBody, PromocoesResponse, CreatePromocaoBody, UpdatePromocaoBody, @@ -27,61 +25,6 @@ export class PoliticasService { } } - async listDescontos(): Promise { - this.assertManager(); - const prisma = this.cls.get('prisma'); - if (!prisma) throw new Error('prisma não disponível no CLS'); - const idEmpresa = this.cls.get('idEmpresa'); - - const rows = await prisma.alcadaDesconto.findMany({ - where: { idEmpresa }, - orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }], - }); - - // Busca nomes dos representantes - const codigos = [...new Set(rows.map((r) => r.codVendedor))]; - const repRows = - codigos.length > 0 - ? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>( - `SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`, - ) - : []; - const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome])); - - return { - descontos: rows.map((r) => ({ - codVendedor: r.codVendedor, - codGrupo: r.codGrupo, - limitePerc: Number(r.limitePerc), - nomeVendedor: repNameMap.get(r.codVendedor) ?? null, - })), - }; - } - - async upsertDesconto(body: UpsertDescontoBody): Promise { - this.assertManager(); - const prisma = this.cls.get('prisma'); - if (!prisma) throw new Error('prisma não disponível no CLS'); - const idEmpresa = this.cls.get('idEmpresa'); - - await prisma.alcadaDesconto.upsert({ - where: { - codVendedor_idEmpresa_codGrupo: { - codVendedor: body.codVendedor, - idEmpresa, - codGrupo: body.codGrupo ?? 0, - }, - }, - create: { - codVendedor: body.codVendedor, - idEmpresa, - codGrupo: body.codGrupo ?? 0, - limitePerc: body.limitePerc, - }, - update: { limitePerc: body.limitePerc }, - }); - } - async listPromocoes(): Promise { this.assertManager(); const prisma = this.cls.get('prisma'); @@ -220,6 +163,23 @@ export class PoliticasService { }; } + // Descrições das pautas informadas — UI sempre mostra o nome, nunca só o id. + private async loadPautaNames(idPautas: number[]): Promise> { + if (idPautas.length === 0) return new Map(); + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; + + const rows = await prisma.$queryRawUnsafe<{ id_pauta: number; descricao: string | null }[]>( + `SELECT id_pauta, TRIM(descricao) AS descricao + FROM vw_pautas + WHERE id_empresa = ${idMatriz} + AND id_pauta IN (${idPautas.join(',')})`, + ); + return new Map(rows.map((r) => [Number(r.id_pauta), r.descricao])); + } + async listRegras(): Promise { this.assertManager(); const prisma = this.cls.get('prisma'); @@ -252,6 +212,9 @@ export class PoliticasService { grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), ); + const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))]; + const pautaNameMap = await this.loadPautaNames(idPautas); + return { regras: rows.map((r) => ({ id: r.id, @@ -260,6 +223,8 @@ export class PoliticasService { grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, codSubgrupo: r.codSubgrupo, subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, + idPauta: r.idPauta, + pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null, codVendedor: r.codVendedor, nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null, descPct: Number(r.descPct), @@ -272,8 +237,9 @@ export class PoliticasService { }; } - // Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e - // cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos. + // Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo + // vira regra por subgrupo e cada pauta vira regra por pauta. O representante + // escolhido é copiado em todas. Sem alvos, regra vale para todos. async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> { this.assertManager(); const prisma = this.cls.get('prisma'); @@ -281,11 +247,17 @@ export class PoliticasService { const idEmpresa = this.cls.get('idEmpresa'); const userId = this.cls.get('userId') ?? 'system'; - const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [ - ...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })), - ...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })), - ]; - if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null }); + const alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] = + [ + ...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null, idPauta: null })), + ...(body.codSubgrupos ?? []).map((s) => ({ + codGrupo: null, + codSubgrupo: s, + idPauta: null, + })), + ...(body.idPautas ?? []).map((p) => ({ codGrupo: null, codSubgrupo: null, idPauta: p })), + ]; + if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null, idPauta: null }); const ids = await prisma.$transaction(async (tx) => { const created: number[] = []; @@ -296,6 +268,7 @@ export class PoliticasService { descricao: body.descricao, codGrupo: alvo.codGrupo, codSubgrupo: alvo.codSubgrupo, + idPauta: alvo.idPauta, codVendedor: body.codVendedor ?? null, descPct: body.descPct, dataInicio: new Date(body.dataInicio), @@ -325,6 +298,7 @@ export class PoliticasService { ...(body.descricao !== undefined && { descricao: body.descricao }), ...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }), ...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }), + ...(body.idPauta !== undefined && { idPauta: body.idPauta }), ...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }), ...(body.descPct !== undefined && { descPct: body.descPct }), ...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }), @@ -453,6 +427,9 @@ export class PoliticasService { grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), ); + const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))]; + const pautaNameMap = await this.loadPautaNames(idPautas); + return { regras: rows.map((r) => ({ id: r.id, @@ -461,6 +438,8 @@ export class PoliticasService { grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, codSubgrupo: r.codSubgrupo, subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, + idPauta: r.idPauta, + pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null, descPct: Number(r.descPct), dataFim: r.dataFim.toISOString().slice(0, 10), })), diff --git a/apps/web/src/cockpits/ger/EquipePage.tsx b/apps/web/src/cockpits/ger/EquipePage.tsx index 262a3f7..61f55ff 100644 --- a/apps/web/src/cockpits/ger/EquipePage.tsx +++ b/apps/web/src/cockpits/ger/EquipePage.tsx @@ -1,7 +1,8 @@ -import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd'; -import type { TableColumnsType } from 'antd'; -import type { RepStats } from '@sar/api-interface'; -import { useEquipe } from '../../lib/queries/gerente'; +import { useState } from 'react'; +import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd'; +import type { SupervisorCard } from '@sar/api-interface'; +import { useSupervisores } from '../../lib/queries/gerente'; +import { SupervisorDetalheModal } from './SupervisorDetalheModal'; const { Title, Text } = Typography; @@ -9,85 +10,118 @@ function fmt(v: number): string { return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } -const columns: TableColumnsType = [ - { - title: 'Representante', - key: 'rep', - render: (_: unknown, row: RepStats) => ( - - {row.nomeVendedor ?? `Cód. ${row.codVendedor}`} - {row.nomeVendedor && ( - - cód. {row.codVendedor} - - )} - - ), - }, - { - title: 'Pedidos', - dataIndex: 'pedidosMes', - width: 90, - align: 'right', - className: 'tabular-nums', - sorter: (a, b) => a.pedidosMes - b.pedidosMes, - render: (v: number) => (v === 0 ? 0 : v), - }, - { - title: 'Faturamento', - dataIndex: 'faturamentoMes', - width: 160, - align: 'right', - className: 'tabular-nums', - sorter: (a, b) => a.faturamentoMes - b.faturamentoMes, - defaultSortOrder: 'descend', - render: (v: number) => fmt(v), - }, - { - title: 'Ticket Medio', - dataIndex: 'ticketMedio', - width: 140, - align: 'right', - className: 'tabular-nums', - sorter: (a, b) => a.ticketMedio - b.ticketMedio, - render: (v: number) => (v > 0 ? fmt(v) : --), - }, - { - title: '% Meta', - dataIndex: 'pctMeta', - width: 170, - sorter: (a, b) => a.pctMeta - b.pctMeta, - render: (pct: number) => - pct === 0 ? ( - sem meta - ) : ( - - = 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'} - showInfo={false} - style={{ flex: 1, minWidth: 60 }} - /> - - {pct}% - +function metaColor(pct: number): string { + return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'; +} + +const MEDALHAS = ['🥇', '🥈', '🥉']; + +function posicaoLabel(i: number): string { + return MEDALHAS[i] ?? `${i + 1}º`; +} + +function SupervisorCardView({ + s, + posicao, + onDetalhar, +}: { + s: SupervisorCard; + posicao: number; + onDetalhar: () => void; +}) { + const lider = posicao === 0; + return ( + + + {/* Cabeçalho: posição + nome */} + + + {posicaoLabel(posicao)} + + + + {s.nomeSupervisor ?? `Supervisor cód. ${s.codSupervisor}`} + + + cód. {s.codSupervisor} · {s.repsAtivos}/{s.repsTotal} reps ativos + + - ), - }, -]; + + {/* Faturamento + variação */} + + + {fmt(s.faturamentoMes)} + + {s.variacaoPct != null && ( + = 0 ? 'var(--green)' : '#cf1322', + }} + > + {s.variacaoPct >= 0 ? '▲' : '▼'} {s.variacaoPct >= 0 ? '+' : ''} + {s.variacaoPct}% vs. mês ant. + + )} + + + {/* Meta do time */} +
+ + + Meta do time + + + {s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'} + + + 0 ? Math.min(s.pctMeta, 100) : 0} + size="small" + strokeColor={metaColor(s.pctMeta)} + showInfo={false} + /> +
+ + {/* Ticket + positivação */} + + + + TICKET MÉDIO + + + {s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'} + + + + + POSITIVAÇÃO + + + {s.pctPositivacao}%{' '} + + ({s.clientesPositivados}/{s.totalClientes}) + + + + + + +
+
+ ); +} export function EquipePage() { - const { data, isLoading } = useEquipe(); - - if (isLoading || !data) { - return ( - - - - - ); - } + const { data, isLoading } = useSupervisores(); + const [detalhe, setDetalhe] = useState(null); const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); @@ -98,25 +132,41 @@ export function EquipePage() { Equipe - Performance dos representantes — {mes} + Disputa dos supervisores — {mes}
- - - rowKey="codVendedor" - columns={columns} - dataSource={data.reps} - pagination={false} - size="small" - locale={{ emptyText: 'Nenhum representante encontrado.' }} - scroll={{ x: 700 }} - /> - + {isLoading || !data ? ( + + {[1, 2, 3].map((i) => ( + + + + + + ))} + + ) : data.supervisores.length === 0 ? ( + + + + ) : ( + <> + + {data.supervisores.map((s: SupervisorCard, i: number) => ( + + setDetalhe(s)} /> + + ))} + - - Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')} - + + Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')} + + + )} + + setDetalhe(null)} /> ); } diff --git a/apps/web/src/cockpits/ger/PoliticasPage.tsx b/apps/web/src/cockpits/ger/PoliticasPage.tsx index 6fdd577..0a15a89 100644 --- a/apps/web/src/cockpits/ger/PoliticasPage.tsx +++ b/apps/web/src/cockpits/ger/PoliticasPage.tsx @@ -24,17 +24,9 @@ import type { TableColumnsType } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs'; -import type { - AlcadaDescontoItem, - Promocao, - RegraDesconto, - RepStats, - GrupoProdutoItem, -} from '@sar/api-interface'; +import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface'; import { - usePoliticasDescontos, usePromocoes, - useUpsertDesconto, useCreatePromocao, useUpdatePromocao, useDeletePromocao, @@ -45,7 +37,7 @@ import { useUpdateRegraDesconto, useDeleteRegraDesconto, } from '../../lib/queries/gerente'; -import { useCatalog } from '../../lib/queries/catalog'; +import { useCatalog, usePautas } from '../../lib/queries/catalog'; // ─── ProdutoSelect: busca remota por código ou descrição ───────────────────── @@ -104,125 +96,6 @@ function ProdutoSelect({ const { Title, Text } = Typography; -// ─── Tab Descontos ──────────────────────────────────────────────────────────── - -function TabDescontos() { - const { data, isLoading } = usePoliticasDescontos(); - const upsert = useUpsertDesconto(); - const [editingKey, setEditingKey] = useState(null); - const [editValue, setEditValue] = useState(0); - const [msg, msgCtx] = message.useMessage(); - - function startEdit(row: AlcadaDescontoItem) { - setEditingKey(`${row.codVendedor}-${row.codGrupo}`); - setEditValue(row.limitePerc); - } - - async function saveEdit(row: AlcadaDescontoItem) { - try { - await upsert.mutateAsync({ - codVendedor: row.codVendedor, - codGrupo: row.codGrupo, - limitePerc: editValue, - }); - } catch { - return; // erro já notificado pelo handler global do QueryClient - } - setEditingKey(null); - void msg.success('Limite atualizado'); - } - - const columns: TableColumnsType = [ - { - title: 'Representante', - key: 'rep', - render: (_: unknown, row: AlcadaDescontoItem) => - row.nomeVendedor ?? `Cód. ${row.codVendedor}`, - }, - { - title: 'Grupo', - dataIndex: 'codGrupo', - width: 100, - render: (v: number) => (v === 0 ? Global : Grupo {v}), - }, - { - title: 'Limite de Desconto', - dataIndex: 'limitePerc', - width: 200, - render: (v: number, row: AlcadaDescontoItem) => { - const key = `${row.codVendedor}-${row.codGrupo}`; - if (editingKey === key) { - return ( - setEditValue(val ?? 0)} - min={0} - max={100} - suffix="%" - size="small" - style={{ width: 100 }} - autoFocus - /> - ); - } - return {v}%; - }, - }, - { - title: '', - width: 120, - render: (_: unknown, row: AlcadaDescontoItem) => { - const key = `${row.codVendedor}-${row.codGrupo}`; - if (editingKey === key) { - return ( - - - - - ); - } - return ( - - ); - }, - }, - ]; - - if (isLoading || !data) return ; - - return ( - <> - {msgCtx} - - rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`} - columns={columns} - dataSource={data.descontos} - pagination={false} - size="small" - locale={{ emptyText: 'Nenhum limite configurado.' }} - /> - - Limite 0 = sem restricao de desconto para este representante. - - - ); -} - // ─── Tab Promocoes ──────────────────────────────────────────────────────────── interface PromocaoForm { @@ -534,8 +407,10 @@ interface RegraForm { codVendedor?: number; codGrupos?: number[]; codSubgrupos?: number[]; + idPautas?: number[]; codGrupo?: number; codSubgrupo?: number; + idPauta?: number; descPct: number; periodo: [dayjs.Dayjs, dayjs.Dayjs]; ativa?: boolean; @@ -545,6 +420,7 @@ function TabRegras() { const { data, isLoading } = useRegrasDesconto(); const { data: equipe } = useEquipe(); const { data: gruposData } = useGruposProduto(); + const { data: pautas } = usePautas(); const createMutation = useCreateRegraDesconto(); const updateMutation = useUpdateRegraDesconto(); const deleteMutation = useDeleteRegraDesconto(); @@ -560,6 +436,12 @@ function TabRegras() { label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`, })); + // Pautas de preços — rótulo com código + descrição (busca encontra pelos dois) + const pautaOptions = (pautas ?? []).map((p) => ({ + value: p.idPauta, + label: `${p.codigo} — ${p.descricao}`, + })); + // Rótulos com código + nome — a busca do select encontra pelos dois. // Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação). const filtroGrupos = new Set([ @@ -603,6 +485,7 @@ function TabRegras() { codVendedor: r.codVendedor ?? undefined, codGrupo: r.codGrupo ?? undefined, codSubgrupo: r.codSubgrupo ?? undefined, + idPauta: r.idPauta ?? undefined, descPct: r.descPct, periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)], ativa: r.ativa, @@ -630,6 +513,7 @@ function TabRegras() { codVendedor: values.codVendedor ?? null, codGrupo: values.codGrupo ?? null, codSubgrupo: values.codSubgrupo ?? null, + idPauta: values.idPauta ?? null, descPct: values.descPct, dataInicio: inicio.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'), @@ -643,6 +527,7 @@ function TabRegras() { codVendedor: values.codVendedor ?? null, codGrupos: values.codGrupos?.length ? values.codGrupos : undefined, codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined, + idPautas: values.idPautas?.length ? values.idPautas : undefined, descPct: values.descPct, dataInicio: inicio.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'), @@ -683,7 +568,7 @@ function TabRegras() { }, { title: 'Grupo / Subgrupo', - width: 220, + width: 200, render: (_: unknown, row: RegraDesconto) => { if (row.codGrupo == null && row.codSubgrupo == null) return Todos; const partes = [ @@ -693,6 +578,16 @@ function TabRegras() { return {partes.join(' / ')}; }, }, + { + title: 'Pauta', + width: 160, + render: (_: unknown, row: RegraDesconto) => + row.idPauta == null ? ( + Todas + ) : ( + {row.pauta ?? `Pauta ${row.idPauta}`} + ), + }, { title: 'Desconto', dataIndex: 'descPct', @@ -782,8 +677,8 @@ function TabRegras() { locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }} /> - A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica - o desconto automaticamente nos pedidos durante a vigencia. + A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e + aplica o desconto automaticamente nos pedidos durante a vigencia. {editTarget ? ( - - + <> + + + + + + + + + - - - - + ) : ( <> + +