feat(web+api): metas por grupo no painel gerencial

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:18:09 +00:00
parent 289c1a071e
commit 5bda00009f
3 changed files with 166 additions and 2 deletions

View File

@@ -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<TotalRow[]>(`
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<MetaGrupoRow[]>(`
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<RealizadoGrupoRow[]>(`
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<number, RealizadoGrupoRow>();
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(),

View File

@@ -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<PositivacaoRep> = [
},
];
const metasGrupoColumns: TableColumnsType<MetaItem> = [
{
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) => (
<Text
className="tabular-nums"
style={{ fontSize: 'var(--text-sm)', color: v === 0 ? 'var(--green)' : undefined }}
>
{v === 0 ? '—' : fmt(v)}
</Text>
),
},
{
title: '% Meta',
dataIndex: 'pct',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
{pct}%
</Text>
</Flex>
),
},
];
const rankingColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
@@ -162,6 +220,7 @@ export function GerPainel() {
totalReps,
promocoesAtivas,
metaTotal,
metasPorGrupo,
rankingReps,
positivacaoReps,
syncedAt,
@@ -361,6 +420,35 @@ export function GerPainel() {
</Row>
)}
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card
title={
<Space>
<FontAwesomeIcon icon={faLayerGroup} style={{ color: 'var(--jcs-blue)' }} />
Metas por Grupo &mdash; {periodoLabel}
</Space>
}
extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{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' : ''}
</Text>
}
>
<Table<MetaItem>
rowKey={(r) => String(r.codigo ?? r.rotulo)}
columns={metasGrupoColumns}
dataSource={metasPorGrupo}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhuma meta por grupo cadastrada.' }}
/>
</Card>
)}
{/* Ranking */}
<Card
title={

View File

@@ -102,6 +102,7 @@ export const ManagerDashboardSchema = z.object({
totalReps: z.number().int(),
promocoesAtivas: z.number().int(),
metaTotal: z.number(),
metasPorGrupo: z.array(MetaItemSchema).default([]),
rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema),
syncedAt: z.iso.datetime(),