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:
@@ -339,6 +339,14 @@ export class DashboardService {
|
|||||||
interface MetaTotalRow {
|
interface MetaTotalRow {
|
||||||
meta_total: string;
|
meta_total: string;
|
||||||
}
|
}
|
||||||
|
interface MetaGrupoRow {
|
||||||
|
cod_grupo: number | null;
|
||||||
|
desc_grupo: string | null;
|
||||||
|
valor: string;
|
||||||
|
qtdade: string;
|
||||||
|
peso: string;
|
||||||
|
vl_fator: string;
|
||||||
|
}
|
||||||
interface PositivacaoRow {
|
interface PositivacaoRow {
|
||||||
cod_vendedor: number;
|
cod_vendedor: number;
|
||||||
nome_vendedor: string | null;
|
nome_vendedor: string | null;
|
||||||
@@ -346,7 +354,15 @@ export class DashboardService {
|
|||||||
clientes_positivados: string;
|
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[]>(`
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
@@ -412,12 +428,70 @@ export class DashboardService {
|
|||||||
HAVING COUNT(DISTINCT c.id_cliente) > 0
|
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
|
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 pedidosMes = Number(statsRows[0]?.count ?? 0);
|
||||||
const faturamentoMes = Number(statsRows[0]?.total ?? 0);
|
const faturamentoMes = Number(statsRows[0]?.total ?? 0);
|
||||||
const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 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 rankingReps: RankingRep[] = rankingRows.map((r) => {
|
||||||
const fat = Number(r.faturamento);
|
const fat = Number(r.faturamento);
|
||||||
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
|
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
|
||||||
@@ -453,6 +527,7 @@ export class DashboardService {
|
|||||||
totalReps,
|
totalReps,
|
||||||
promocoesAtivas: Number(promoRows[0]?.count ?? 0),
|
promocoesAtivas: Number(promoRows[0]?.count ?? 0),
|
||||||
metaTotal,
|
metaTotal,
|
||||||
|
metasPorGrupo,
|
||||||
rankingReps,
|
rankingReps,
|
||||||
positivacaoReps,
|
positivacaoReps,
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import {
|
|||||||
faAddressCard,
|
faAddressCard,
|
||||||
faBullseye,
|
faBullseye,
|
||||||
faArrowTrendUp,
|
faArrowTrendUp,
|
||||||
|
faLayerGroup,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { 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';
|
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
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> = [
|
const rankingColumns: TableColumnsType<RankingRep> = [
|
||||||
{
|
{
|
||||||
title: 'Representante',
|
title: 'Representante',
|
||||||
@@ -162,6 +220,7 @@ export function GerPainel() {
|
|||||||
totalReps,
|
totalReps,
|
||||||
promocoesAtivas,
|
promocoesAtivas,
|
||||||
metaTotal,
|
metaTotal,
|
||||||
|
metasPorGrupo,
|
||||||
rankingReps,
|
rankingReps,
|
||||||
positivacaoReps,
|
positivacaoReps,
|
||||||
syncedAt,
|
syncedAt,
|
||||||
@@ -361,6 +420,35 @@ export function GerPainel() {
|
|||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Metas por Grupo */}
|
||||||
|
{metasPorGrupo.length > 0 && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faLayerGroup} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Metas por Grupo — {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 */}
|
{/* Ranking */}
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const ManagerDashboardSchema = z.object({
|
|||||||
totalReps: z.number().int(),
|
totalReps: z.number().int(),
|
||||||
promocoesAtivas: z.number().int(),
|
promocoesAtivas: z.number().int(),
|
||||||
metaTotal: z.number(),
|
metaTotal: z.number(),
|
||||||
|
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
||||||
rankingReps: z.array(RankingRepSchema),
|
rankingReps: z.array(RankingRepSchema),
|
||||||
positivacaoReps: z.array(PositivacaoRepSchema),
|
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
|
|||||||
Reference in New Issue
Block a user