feat(web+api): gráfico anual realizado vs meta no painel do representante

Barras (azul/verde) por mês + linha tracejada de meta; meses futuros em cinza.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:51:20 +00:00
parent a6cb1df2aa
commit 795b805ae5
3 changed files with 222 additions and 12 deletions

View File

@@ -124,7 +124,24 @@ export class DashboardService {
obs: string | null;
}
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
interface RealizadoMesRow {
mes: string;
valor: string;
}
interface MetaMesRow {
mes: string;
tipo: string;
valor: string;
}
const [
atingidoRows,
pedidosMesRows,
recentRows,
realizadoGrupoRows,
realizadoMesRows,
metaMesRows,
] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COALESCE(SUM(total), 0)::text AS total
FROM vw_pedidos_erp
@@ -178,6 +195,30 @@ export class DashboardService {
AND e.dt_pedido <= '${monthEndStr}'
GROUP BY p.cod_grupo, grupo
`),
// Realizado por mês do ano corrente.
prisma.$queryRawUnsafe<RealizadoMesRow[]>(`
SELECT EXTRACT(MONTH FROM dt_pedido)::int::text AS mes,
COALESCE(SUM(total), 0)::text AS valor
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa NOT IN (1, 5)
AND EXTRACT(YEAR FROM dt_pedido) = ${year}
GROUP BY mes
ORDER BY mes
`),
// Meta (GL + GR) por mês do ano corrente.
prisma.$queryRawUnsafe<MetaMesRow[]>(`
SELECT mes::text, TRIM(tipo) AS tipo,
COALESCE(SUM(valor), 0)::text AS valor
FROM vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND cod_vendedor = ${codVendedor}
AND ano = ${year}
AND TRIM(tipo) IN ('GL', 'GR')
GROUP BY mes, tipo
ORDER BY mes
`),
]);
const atingido = Number(atingidoRows[0]?.total ?? 0);
@@ -267,21 +308,48 @@ export class DashboardService {
})
.sort((a, b) => b.valorMeta - a.valorMeta);
const naoPositivados = naoPositivadosRows.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
razao: c.razao ?? null,
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
ultimoPedido: c.dt_ultimo_pedido ?? null,
comprouAntes: Boolean(c.comprou_antes),
whatsapp: c.whatsapp ?? null,
}));
// Deduplica por idCliente — vw_clientes pode retornar o mesmo cliente em
// mais de uma empresa; mantemos a primeira ocorrência (ORDER BY dt_max ASC NULLS FIRST).
const seenNP = new Set<number>();
const naoPositivados = naoPositivadosRows
.filter((c) => {
const id = Number(c.id_cliente);
if (seenNP.has(id)) return false;
seenNP.add(id);
return true;
})
.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
razao: c.razao ?? null,
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
ultimoPedido: c.dt_ultimo_pedido ?? null,
comprouAntes: Boolean(c.comprou_antes),
whatsapp: c.whatsapp ?? null,
}));
// Monta histório mensal: 12 meses do ano, cruzando realizado e meta.
const realizadoPorMes = new Map(realizadoMesRows.map((r) => [Number(r.mes), Number(r.valor)]));
const metaGlPorMes = new Map<number, number>();
const metaGrPorMes = new Map<number, number>();
for (const r of metaMesRows) {
const m = Number(r.mes);
if (r.tipo === 'GL') metaGlPorMes.set(m, (metaGlPorMes.get(m) ?? 0) + Number(r.valor));
else metaGrPorMes.set(m, (metaGrPorMes.get(m) ?? 0) + Number(r.valor));
}
const historicoMensal = Array.from({ length: 12 }, (_, i) => {
const m = i + 1;
const valor = realizadoPorMes.get(m) ?? 0;
const meta = metaGlPorMes.has(m) ? (metaGlPorMes.get(m) ?? 0) : (metaGrPorMes.get(m) ?? 0);
return { mes: m, valor, meta, atingiu: meta > 0 && valor >= meta };
});
return {
meta: { atingido, total: targetAmount, pct, falta },
metaDimensao,
metasPorGrupo,
pedidosMes,
historicoMensal,
pedidosRecentes: recentRows.map((o) => ({
id: `erp-${o.id_pedido}`,
numPedSar: (o.num_ped_sar ?? '').trim(),