feat(web+api): cockpit gerente — painel, equipe, políticas e positivação
## API - GET /dashboard/manager: KPIs agregados (faturamento, pedidos, ticket médio, promoções ativas), meta total do período, ranking top 10 com clientes atendidos e % meta, positivação por representante, venda necessária/dia - Parâmetros opcionais ?mes&ano para filtrar período - GET /equipe: lista de reps com pedidos, faturamento, ticket médio e % meta (deduplicação de vw_representantes) - GET/POST /politicas/descontos: alçada de desconto por rep (AlcadaDesconto) - GET/POST/PATCH/DELETE /politicas/promocoes: CRUD de promoções com validade - Metas lidas de sar.vw_metas (tipo=GR), não de vw_metas do ERP ## Schema - Novo model Promocao (sar.promocoes) criado via prisma db execute ## Frontend - Cockpit /ger: GerPainel, EquipePage, PoliticasPage - GerPainel: filtro mes/ano, cards KPI, cards de meta vs realizado (atingimento, falta, venda/dia), ranking com clientes atendidos, positivação de clientes por representante (paginada) - Sidebar e BottomNav role-aware: rep / supervisor / gerente (manager+admin) - Clientes acessível ao gerente (carteira completa de todos os reps) - Rotas: HomeRoute redireciona por role; /ger, /ger/equipe, /ger/politicas Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
|
||||
import type {
|
||||
RepDashboard,
|
||||
SupervisorDashboard,
|
||||
ManagerDashboard,
|
||||
RankingRep,
|
||||
PositivacaoRep,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
|
||||
@@ -300,6 +306,159 @@ export class DashboardService {
|
||||
};
|
||||
}
|
||||
|
||||
async managerDashboard(mes?: number, ano?: number): Promise<ManagerDashboard> {
|
||||
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();
|
||||
|
||||
function matrizEmpresa(id: number): number {
|
||||
return id > 9000 ? id - 9000 : id;
|
||||
}
|
||||
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||
const year = ano ?? now.getFullYear();
|
||||
const month = mes ?? 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);
|
||||
|
||||
interface TotalRow {
|
||||
count: string;
|
||||
total: string;
|
||||
}
|
||||
interface RankingRow {
|
||||
cod_vendedor: number;
|
||||
nome: string | null;
|
||||
pedidos: string;
|
||||
clientes_atendidos: string;
|
||||
faturamento: string;
|
||||
meta_valor: string | null;
|
||||
}
|
||||
interface PromoRow {
|
||||
count: string;
|
||||
}
|
||||
interface MetaTotalRow {
|
||||
meta_total: string;
|
||||
}
|
||||
interface PositivacaoRow {
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
total_clientes: string;
|
||||
clientes_positivados: string;
|
||||
}
|
||||
|
||||
const [statsRows, rankingRows, promoRows, metaTotalRows, positivacaoRows] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
`),
|
||||
prisma.$queryRawUnsafe<RankingRow[]>(`
|
||||
SELECT p.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r
|
||||
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
|
||||
COUNT(*)::text AS pedidos,
|
||||
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
|
||||
COALESCE(SUM(p.total), 0)::text AS faturamento,
|
||||
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
|
||||
WHERE m.id_empresa = ${idEmpresaMatriz}
|
||||
AND m.cod_vendedor = p.cod_vendedor
|
||||
AND m.ano = ${year}
|
||||
AND m.mes = ${month}
|
||||
AND TRIM(m.tipo) = 'GR') AS meta_valor
|
||||
FROM vw_pedidos_erp p
|
||||
WHERE p.id_empresa = ${idEmpresa}
|
||||
AND p.situa NOT IN (1, 5)
|
||||
AND p.dt_pedido >= '${monthStartStr}'
|
||||
AND p.dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY p.cod_vendedor
|
||||
ORDER BY SUM(p.total) DESC
|
||||
LIMIT 10
|
||||
`),
|
||||
prisma.$queryRawUnsafe<PromoRow[]>(`
|
||||
SELECT COUNT(*)::text AS count
|
||||
FROM sar.promocoes
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND ativa = true
|
||||
AND data_fim >= CURRENT_DATE
|
||||
`),
|
||||
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
|
||||
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
|
||||
FROM sar.vw_metas
|
||||
WHERE id_empresa = ${idEmpresaMatriz}
|
||||
AND ano = ${year}
|
||||
AND mes = ${month}
|
||||
AND TRIM(tipo) = 'GR'
|
||||
`),
|
||||
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
|
||||
SELECT
|
||||
c.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_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 clientes_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
|
||||
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
|
||||
`),
|
||||
]);
|
||||
|
||||
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
||||
const faturamentoMes = Number(statsRows[0]?.total ?? 0);
|
||||
const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 0;
|
||||
|
||||
const rankingReps: RankingRep[] = rankingRows.map((r) => {
|
||||
const fat = Number(r.faturamento);
|
||||
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
|
||||
return {
|
||||
codVendedor: Number(r.cod_vendedor),
|
||||
nomeVendedor: r.nome ?? null,
|
||||
pedidos: Number(r.pedidos),
|
||||
clientesAtendidos: Number(r.clientes_atendidos),
|
||||
faturamento: fat,
|
||||
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
|
||||
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
||||
|
||||
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
||||
const total = Number(r.total_clientes);
|
||||
const positivados = Number(r.clientes_positivados);
|
||||
return {
|
||||
codVendedor: Number(r.cod_vendedor),
|
||||
nomeVendedor: r.nome_vendedor ?? null,
|
||||
totalClientes: total,
|
||||
clientesPositivados: positivados,
|
||||
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
faturamentoMes,
|
||||
pedidosMes,
|
||||
ticketMedio,
|
||||
totalReps,
|
||||
promocoesAtivas: Number(promoRows[0]?.count ?? 0),
|
||||
metaTotal,
|
||||
rankingReps,
|
||||
positivacaoReps,
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
Reference in New Issue
Block a user