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(),

View File

@@ -18,13 +18,35 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowTrendUp,
faBullseye,
faChartBar,
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { WhatsAppOutlined } from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip as ChartTooltip,
Legend,
} from 'chart.js';
import { Chart } from 'react-chartjs-2';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
@@ -148,6 +170,109 @@ const metaColumns: TableColumnsType<MetaItem> = [
},
];
// ─── Gráfico anual ────────────────────────────────────────────────────────────
const MESES = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
function GraficoAnual({
dados,
ano,
mesAtual,
}: {
dados: MesAno[];
ano: number;
mesAtual: number;
}) {
const labels = MESES;
const barColors = dados.map((d) => {
if (d.mes > mesAtual) return 'rgba(203,213,225,0.5)'; // futuro — cinza claro
if (d.mes === mesAtual) return d.atingiu ? 'rgba(56,158,13,0.75)' : 'rgba(0,59,142,0.75)'; // mês atual
return d.atingiu ? 'rgba(56,158,13,0.85)' : 'rgba(0,59,142,0.65)'; // passado
});
const borderColors = dados.map((d) => (d.mes === mesAtual ? '#1a1a1a' : 'transparent'));
const chartData = {
labels,
datasets: [
{
type: 'bar' as const,
label: 'Realizado',
data: dados.map((d) => (d.mes <= mesAtual ? d.valor : null)),
backgroundColor: barColors,
borderColor: borderColors,
borderWidth: dados.map((d) => (d.mes === mesAtual ? 2 : 0)),
borderRadius: 4,
order: 2,
},
{
type: 'line' as const,
label: 'Meta',
data: dados.map((d) => (d.meta > 0 ? d.meta : null)),
borderColor: '#f5222d',
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 4],
pointRadius: dados.map((d) => (d.meta > 0 ? 4 : 0)),
pointBackgroundColor: dados.map((d) =>
d.meta > 0 && d.mes <= mesAtual ? (d.atingiu ? '#52c41a' : '#f5222d') : '#f5222d',
),
order: 1,
tension: 0.1,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
const val = ctx.parsed.y;
if (val == null) return '';
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`;
},
},
},
},
scales: {
x: { grid: { display: false } },
y: {
ticks: {
callback: (v: number | string) =>
Number(v).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
notation: 'compact',
}),
font: { size: 10 },
},
grid: { color: '#f0f0f0' },
},
},
};
return (
<Card
title={
<Space>
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
Realizado vs Meta {ano}
</Space>
}
>
<div style={{ height: 240 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
</Card>
);
}
// ─── Não positivados ──────────────────────────────────────────────────────────
type OrdemNP = 'longe' | 'recentes';
@@ -204,7 +329,7 @@ function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[];
const dias = c.diasSemPedido ?? 999;
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
return (
<Space direction="vertical" size={0}>
<Space orientation="vertical" size={0}>
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
{dias}d sem pedido
</Tag>
@@ -339,9 +464,14 @@ export function RepPainel() {
pedidosRecentes = [],
naoPositivados = [],
totalNaoPositivados = 0,
historicoMensal = [],
syncedAt,
} = data;
const now = new Date();
const anoAtual = now.getFullYear();
const mesAtual = now.getMonth() + 1;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Saudação */}
@@ -499,6 +629,9 @@ export function RepPainel() {
</Card>
)}
{/* Gráfico anual */}
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
{/* Linha 2 — Não positivados + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={14}>

View File

@@ -39,6 +39,14 @@ export const MetaItemSchema = z.object({
});
export type MetaItem = z.infer<typeof MetaItemSchema>;
export const MesAnoSchema = z.object({
mes: z.number().int().min(1).max(12),
valor: z.number(),
meta: z.number(),
atingiu: z.boolean(),
});
export type MesAno = z.infer<typeof MesAnoSchema>;
export const RepDashboardSchema = z.object({
meta: z.object({
atingido: z.number(),
@@ -52,6 +60,7 @@ export const RepDashboardSchema = z.object({
pedidosRecentes: z.array(PedidoSummarySchema),
naoPositivados: z.array(ClienteNaoPositivadoSchema),
totalNaoPositivados: z.number().int(),
historicoMensal: z.array(MesAnoSchema).default([]),
syncedAt: z.iso.datetime(),
});
export type RepDashboard = z.infer<typeof RepDashboardSchema>;