From 5bda00009f379d46e4549b7c6dfe9c0f01766439 Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 26 Jun 2026 13:18:09 +0000 Subject: [PATCH] feat(web+api): metas por grupo no painel gerencial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega GR-metas por cod_grupo (todos os reps) e realizado por grupo via JOIN em vw_peditens_erp/vw_produtos, retornando MetaItem[] no endpoint /dashboard/manager. GerPainel exibe tabela com Meta / Realizado / Falta / % Meta (barra + número) ordenada por valorMeta desc; card oculto quando não há grupos cadastrados. Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/dashboard/dashboard.service.ts | 77 +++++++++++++++- apps/web/src/cockpits/ger/GerPainel.tsx | 90 ++++++++++++++++++- .../src/lib/dashboard.contract.ts | 1 + 3 files changed, 166 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 65b188d..c1ef200 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -339,6 +339,14 @@ export class DashboardService { interface MetaTotalRow { meta_total: string; } + interface MetaGrupoRow { + cod_grupo: number | null; + desc_grupo: string | null; + valor: string; + qtdade: string; + peso: string; + vl_fator: string; + } interface PositivacaoRow { cod_vendedor: number; nome_vendedor: string | null; @@ -346,7 +354,15 @@ export class DashboardService { clientes_positivados: string; } - const [statsRows, rankingRows, promoRows, metaTotalRows, positivacaoRows] = await Promise.all([ + const [ + statsRows, + rankingRows, + promoRows, + metaTotalRows, + positivacaoRows, + metaGrupoRows, + realizadoGrupoRows, + ] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total FROM vw_pedidos_erp @@ -412,12 +428,70 @@ export class DashboardService { HAVING COUNT(DISTINCT c.id_cliente) > 0 ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC `), + prisma.$queryRawUnsafe(` + SELECT cod_grupo, TRIM(desc_grupo) AS desc_grupo, + SUM(valor)::text AS valor, + SUM(qtdade)::text AS qtdade, + SUM(peso)::text AS peso, + AVG(vl_fator)::text AS vl_fator + FROM sar.vw_metas + WHERE id_empresa = ${idEmpresaMatriz} + AND ano = ${year} + AND mes = ${month} + AND TRIM(tipo) = 'GR' + GROUP BY cod_grupo, desc_grupo + ORDER BY SUM(valor) DESC + `), + prisma.$queryRawUnsafe(` + SELECT p.cod_grupo, + COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo, + COUNT(DISTINCT pi.id_pedido)::text AS pedidos, + COALESCE(SUM(pi.total), 0)::text AS valor, + COALESCE(SUM(pi.qtd), 0)::text AS qtd, + COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso + FROM vw_peditens_erp pi + JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido + JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz} + WHERE e.id_empresa = ${idEmpresa} + AND e.situa NOT IN (1, 5) + AND e.dt_pedido >= '${monthStartStr}' + AND e.dt_pedido <= '${monthEndStr}' + GROUP BY p.cod_grupo, grupo + `), ]); const pedidosMes = Number(statsRows[0]?.count ?? 0); const faturamentoMes = Number(statsRows[0]?.total ?? 0); const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 0; + const realGrupoPorCod = new Map(); + for (const r of realizadoGrupoRows) { + if (r.cod_grupo != null) realGrupoPorCod.set(Number(r.cod_grupo), r); + } + const metasPorGrupo = metaGrupoRows.map((m) => { + const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null; + const real = cod != null ? realGrupoPorCod.get(cod) : undefined; + const valorMeta = Number(m.valor); + const valorReal = real ? Number(real.valor) : 0; + const pesoMeta = Number(m.peso); + const pesoReal = real ? Number(real.peso) : 0; + return { + codigo: cod, + rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`, + pedidos: real ? Number(real.pedidos) : 0, + valorMeta, + valorReal, + qtdMeta: Number(m.qtdade), + qtdReal: real ? Number(real.qtd) : 0, + pesoMeta, + pesoReal, + fatorMeta: Number(m.vl_fator), + fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0, + pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0, + falta: Math.max(0, valorMeta - valorReal), + }; + }); + const rankingReps: RankingRep[] = rankingRows.map((r) => { const fat = Number(r.faturamento); const meta = r.meta_valor ? Number(r.meta_valor) : 0; @@ -453,6 +527,7 @@ export class DashboardService { totalReps, promocoesAtivas: Number(promoRows[0]?.count ?? 0), metaTotal, + metasPorGrupo, rankingReps, positivacaoReps, syncedAt: now.toISOString(), diff --git a/apps/web/src/cockpits/ger/GerPainel.tsx b/apps/web/src/cockpits/ger/GerPainel.tsx index 696d791..7028356 100644 --- a/apps/web/src/cockpits/ger/GerPainel.tsx +++ b/apps/web/src/cockpits/ger/GerPainel.tsx @@ -23,10 +23,11 @@ import { faAddressCard, faBullseye, faArrowTrendUp, + faLayerGroup, } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; -import type { RankingRep, PositivacaoRep } from '@sar/api-interface'; +import type { RankingRep, PositivacaoRep, MetaItem } from '@sar/api-interface'; import { useManagerDashboard } from '../../lib/queries/gerente'; const { Title, Text } = Typography; @@ -78,6 +79,63 @@ const positivacaoColumns: TableColumnsType = [ }, ]; +const metasGrupoColumns: TableColumnsType = [ + { + title: 'Grupo', + dataIndex: 'rotulo', + ellipsis: true, + }, + { + title: 'Meta', + dataIndex: 'valorMeta', + width: 150, + align: 'right', + render: (v: number) => fmt(v), + className: 'tabular-nums', + }, + { + title: 'Realizado', + dataIndex: 'valorReal', + width: 150, + align: 'right', + render: (v: number) => fmt(v), + className: 'tabular-nums', + }, + { + title: 'Falta', + dataIndex: 'falta', + width: 130, + align: 'right', + render: (v: number) => ( + + {v === 0 ? '—' : fmt(v)} + + ), + }, + { + title: '% Meta', + dataIndex: 'pct', + width: 170, + render: (pct: number) => ( + + = 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'} + showInfo={false} + style={{ flex: 1, minWidth: 60 }} + /> + + {pct}% + + + ), + }, +]; + const rankingColumns: TableColumnsType = [ { title: 'Representante', @@ -162,6 +220,7 @@ export function GerPainel() { totalReps, promocoesAtivas, metaTotal, + metasPorGrupo, rankingReps, positivacaoReps, syncedAt, @@ -361,6 +420,35 @@ export function GerPainel() { )} + {/* Metas por Grupo */} + {metasPorGrupo.length > 0 && ( + + + Metas por Grupo — {periodoLabel} + + } + extra={ + + {metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length} de {metasPorGrupo.length}{' '} + grupo + {metasPorGrupo.length !== 1 ? 's' : ''} atingido + {metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length !== 1 ? 's' : ''} + + } + > + + rowKey={(r) => String(r.codigo ?? r.rotulo)} + columns={metasGrupoColumns} + dataSource={metasPorGrupo} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhuma meta por grupo cadastrada.' }} + /> + + )} + {/* Ranking */}